Skip to content

Commit fb0cf2f

Browse files
committed
test(api): added v3 product catalog e2e tests
1 parent 696f18f commit fb0cf2f

9 files changed

Lines changed: 2187 additions & 20 deletions

File tree

.agents/skills/e2e/SKILL.md

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
---
2+
name: e2e
3+
description: Write end-to-end tests for OpenMeter against a live server. Use when adding tests under e2e/ that exercise API endpoints over HTTP (v1 generated SDK or v3 raw HTTP).
4+
user-invocable: true
5+
argument-hint: "[feature or scenario to test]"
6+
allowed-tools: Read, Edit, Write, Bash, Grep, Glob, Agent
7+
---
8+
9+
# End-to-End Testing
10+
11+
You are helping the user write OpenMeter end-to-end tests that run against a live HTTP server with real dependencies (Postgres, Kafka, ClickHouse, Svix).
12+
13+
This is the black-box layer. Unlike the `/test` skill (which covers in-process unit/integration/service tests using `testutils.TestEnv` + `testutils.InitPostgresDB`), e2e tests hit the wire format: JSON in, JSON out, status codes, problem+json error bodies. Use this skill when the value of the test comes from exercising the HTTP contract, the OpenAPI binder, or cross-service behavior.
14+
15+
## Two styles, same package
16+
17+
Both live in `e2e/` and share the build tag, environment, and skip-when-unset convention. Pick by what the endpoint under test offers:
18+
19+
| Style | When to use | Client | Reference |
20+
|-------|-------------|--------|-----------|
21+
| **v1 SDK** | Endpoint has generated Go SDK coverage (ingest, meters, subjects, customers, v1 plans, entitlements) | `initClient(t) *api.ClientWithResponses` from `setup_test.go` | `e2e_test.go`, `entitlement_test.go`, `multisubject_test.go` |
22+
| **v3 raw HTTP** | Endpoint lives under `/api/v3/...` (no SDK yet) — plans, addons, plan-addons, v3 meter query, etc. | `newV3Client(t) *v3Client` from `v3helpers_test.go` | `plans_v3_test.go`, `addons_v3_test.go`, `planaddons_v3_test.go` |
23+
24+
Mixed files are fine — e.g., a v3 test that needs a v1 feature can call `initClient(t)` for the feature setup and `newV3Client(t)` for the assertion.
25+
26+
## Running tests
27+
28+
```bash
29+
make etoe # Full suite (starts docker-compose stack + tests)
30+
```
31+
32+
Prereqs: `make up` (or `docker compose -f e2e/docker-compose.infra.yaml up -d`) to bring up Postgres/Kafka/ClickHouse/Svix, plus an OpenMeter server reachable on `$OPENMETER_ADDRESS`.
33+
34+
Targeted run:
35+
36+
```bash
37+
TZ=UTC OPENMETER_ADDRESS=http://localhost:8888 go test -count=1 -tags=dynamic -v -run '^Test<Name>$' ./e2e/
38+
```
39+
40+
Notes:
41+
42+
- Build tag `-tags=dynamic` is mandatory (confluent-kafka-go).
43+
- Tests **skip automatically** when `OPENMETER_ADDRESS` is unset — the skip lives in `initClient` and `newV3Client`.
44+
- `RUN_SLOW_TESTS=1` enables scenarios gated by `shouldRunSlowTests(t)` (`setup_test.go:26`).
45+
- `-count=1` bypasses the go-test result cache; useful when iterating against a changing server.
46+
- If `go`/`gofmt` are missing from the ambient shell, fall back to `nix develop --impure .#ci -c <command>` (see AGENTS.md).
47+
- Run commands directly — do not wrap in `sh -lc`/`bash -lc`. For env vars, prefer `env KEY=VALUE <command>` or `KEY=VALUE <command>`.
48+
49+
## Shared conventions (both styles)
50+
51+
### Unique fixture keys
52+
53+
The docker-compose DB is shared across re-runs and parallel tests. Fixed keys collide. Always generate keys with a suffix:
54+
55+
```go
56+
// v3 (v3helpers_test.go)
57+
uniqueKey("prefix") // "prefix_<millis>_<rand>"
58+
validPlanRequest("prefix") // calls uniqueKey internally
59+
```
60+
61+
For v1 tests, use `ulid.Make().String()` or a `fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())` to the same effect.
62+
63+
### Page size for list-to-find
64+
65+
Default server page size is 20. When a test creates a fixture and then lists to locate it, bump the page size or the fresh row may sit past page 1 on a busy DB:
66+
67+
```go
68+
c.ListPlans(withPageSize(1000)) // v3 helper
69+
// v1: pass page_size via the generated params struct
70+
```
71+
72+
### Decimal normalization
73+
74+
The server trims trailing zeros and canonicalizes decimals on round-trip: `"0.10"` comes back as `"0.1"`. Parse as float or use the normalized form; never assert on the raw input string.
75+
76+
### Per-request timeout
77+
78+
The v3 harness wraps every request in a 30s context (`v3RequestTimeout` in `v3helpers_test.go`). A server-side hang surfaces in seconds instead of eating the whole 10-minute `go test` deadline. Keep that bound when adding new wrappers.
79+
80+
### Context
81+
82+
Use `t.Context()` in e2e tests too — it ties cancellation to the test harness and matches the rest of the repo.
83+
84+
## v1 SDK style
85+
86+
The generated client at `api/client/go` exposes `<Endpoint>WithResponse` methods that return typed response structs with `StatusCode()`, `JSON200`, `JSON201`, etc. Shared helpers in `e2e/helpers.go` wrap the common multi-step flows (create customer + subject, lookup meter by slug, v3 meter query that pre-dates the full v3 harness).
87+
88+
```go
89+
func TestIngest(t *testing.T) {
90+
client := initClient(t)
91+
92+
resp, err := client.UpsertSubjectWithResponse(t.Context(), api.UpsertSubjectJSONRequestBody{
93+
api.SubjectUpsert{Key: "customer-1"},
94+
})
95+
require.NoError(t, err)
96+
require.Equal(t, http.StatusOK, resp.StatusCode())
97+
}
98+
```
99+
100+
Patterns worth reusing:
101+
102+
- Error body access: `string(resp.Body)` — the generated client keeps the raw bytes for diagnostics.
103+
- Eventual consistency: `assert.EventuallyWithT(...)` when the test writes an event and then queries the meter (ingestion is async through Kafka).
104+
- Error shape on 4xx: the generated SDK parses into `resp.ApplicationproblemJSON400.Extensions.ValidationErrors[N].Code` for v1 domain validation — see the older `productcatalog_test.go` for examples.
105+
106+
## v3 raw HTTP style
107+
108+
The v3 Go SDK isn't generated yet, so tests build requests from `apiv3.*` structs and decode success bodies themselves. `v3helpers_test.go` owns the HTTP plumbing:
109+
110+
```go
111+
func TestV3<Entity><Behavior>(t *testing.T) {
112+
c := newV3Client(t)
113+
114+
body := validPlanRequest("descriptive_prefix")
115+
// mutate body as needed...
116+
117+
status, plan, problem := c.CreatePlan(body)
118+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
119+
require.NotNil(t, plan)
120+
121+
assert.Equal(t, apiv3.BillingPlanStatusDraft, plan.Status)
122+
}
123+
```
124+
125+
All typed wrappers return `(status, *T, *v3Problem)`:
126+
- `*T` is populated only on the expected 2xx.
127+
- `*v3Problem` is populated only when the response is 4xx/5xx and parses as `application/problem+json`.
128+
- `c.do(method, path, body)` is the low-level escape hatch — returns `(status, raw, *v3Problem)`.
129+
130+
Delete/Detach wrappers (`DeletePlan`, `DeleteAddon`, `DetachAddon`) have no response body, so they omit the `*T` and return `(status, *v3Problem)`.
131+
132+
Extending the harness:
133+
- New endpoint family → add typed wrappers using `decodeTyped[T]` so the `(status, *T, *problem)` contract stays consistent.
134+
- New fixture kind → add a `valid<Thing>Request("prefix")` builder that internally calls `uniqueKey` so callers never have to think about collisions.
135+
- New assertion shape → add `assert<Shape>(t, problem, ...)` next to the existing helpers.
136+
137+
## Error-shape triage (v3)
138+
139+
v3 handlers return **three** distinct error shapes on 4xx responses. The harness parses all three into the same `*v3Problem`. Pick the assertion helper by **shape**, not by scenario intent.
140+
141+
| Shape | Produced by | Example | Helper |
142+
|-------|-------------|---------|--------|
143+
| Domain validation | `commonhttp.HandleIssueIfHTTPStatusKnown` — any handler that returns `models.ValidationIssue`s | `extensions.validationErrors[].code` = `"plan_phase_duplicated_key"` | `assertValidationCode(t, problem, "<code>")` |
144+
| API error with free-text `Detail` | `api/v3/apierrors/errors.go``BaseAPIError` and typed errors like `FeatureNotFoundError` | `"only Plans in [draft scheduled] can be updated"`, `"feature with ID … not found"` | `assertProblemDetail(t, problem, "<substring>")` |
145+
| Schema / request binder | oapi-codegen binder (fires before any handler) | `invalid_parameters[].rule` = `"min_items"`, `"required"`, `"enum"` | `assertInvalidParameterRule(t, problem, "<rule>")` |
146+
147+
You cannot predict which shape a new check uses until you see the response. **Write the test, run it once, inspect the raw problem via the `"problem: %+v"` failure message, then pick the helper.** If `extensions.validationErrors` is empty but `Detail` carries the reason, switch to `assertProblemDetail`. If neither is set but `invalid_parameters` is populated, switch to `assertInvalidParameterRule`.
148+
149+
A word of caution on `assertProblemDetail`: the substring you match is free-text server output. It's a fragile assertion — any edit to the error message will break the test. Use it only when the other two shapes don't apply, and keep the substring short and distinctive.
150+
151+
## Validation moments: create vs. publish vs. get
152+
153+
Some v3 entities (plans, addons, plan-addons today) support **draft** lifecycle states. Not every defect is rejected at create — several are accepted as drafts, surface as `validation_errors` on GET, and fire only at publish. Three moments, three assertion sites:
154+
155+
1. **Create-time** (`POST /<resource>` → 400) — schema errors (min_items, required) and a small set of domain checks.
156+
2. **Get-time** (`GET /<resource>/{id}` → 200 with `validation_errors` populated on the body) — soft surface for UIs.
157+
3. **Publish-time** (`POST /<resource>/{id}/publish` → 400) — most domain rules land here.
158+
159+
Before asserting 400-at-create, **run the request**. If you get 201, pivot to the draft-with-errors shape (see `TestV3PlanInvalidDraftLifecycle` for the canonical three-step flow: create draft → GET shows errors → publish rejects with the same code → fix via PUT → publish succeeds).
160+
161+
Rule of thumb: the moment a check fires is a server-side choice that can shift between releases. Pin tests to one moment and you risk spurious failures when the server tightens or loosens. If a check is important, exercise both the draft-with-errors GET and the publish rejection; it costs little extra and survives reasonable server evolution.
162+
163+
## Patterns
164+
165+
### Lifecycle (ordered subtests sharing state)
166+
167+
When the scenario reads as "create → update → publish → archive → delete", group the steps as `t.Run` subtests under a single outer-test client. Subtest names describe the **step**, not the expected status.
168+
169+
Reference: `e2e/plans_v3_test.go` `TestV3PlanLifecycle`, `e2e/addons_v3_test.go` `TestV3Addon`.
170+
171+
```go
172+
func TestV3<Entity>Lifecycle(t *testing.T) {
173+
c := newV3Client(t)
174+
175+
createBody := valid<Entity>Request("lifecycle")
176+
var entityID string
177+
178+
t.Run("Should create the entity in draft status", func(t *testing.T) {
179+
status, e, problem := c.Create<Entity>(createBody)
180+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
181+
require.NotNil(t, e)
182+
entityID = e.Id
183+
})
184+
185+
t.Run("Should publish the entity", func(t *testing.T) {
186+
require.NotEmpty(t, entityID)
187+
status, e, problem := c.Publish<Entity>(entityID)
188+
require.Equal(t, http.StatusOK, status, "problem: %+v", problem)
189+
assert.Equal(t, apiv3.<Entity>StatusActive, e.Status)
190+
})
191+
192+
// ... archive, delete, etc.
193+
}
194+
```
195+
196+
### Table-driven validation (independent subtests)
197+
198+
For validation matrices (status × status, instance-type × quantity, etc.), each row gets a fresh client. This scopes `require.X` failures to the row, not the outer table.
199+
200+
Reference: `e2e/planaddons_v3_test.go` `TestV3PlanAddonAttachStatusMatrix`.
201+
202+
```go
203+
func TestV3<Something>Matrix(t *testing.T) {
204+
cases := []struct {
205+
name string
206+
mutate func(*apiv3.Create<X>Request)
207+
expectedStatus int
208+
expectedCode string // domain-validation code; empty for 2xx or non-PC shapes
209+
expectedDetailIn string // substring of Detail; alternative to expectedCode
210+
}{
211+
{name: "valid baseline → 201", mutate: func(*apiv3.Create<X>Request) {}, expectedStatus: http.StatusCreated},
212+
// ... more rows
213+
}
214+
215+
for _, tc := range cases {
216+
t.Run(tc.name, func(t *testing.T) {
217+
c := newV3Client(t)
218+
219+
body := valid<X>Request("matrix")
220+
tc.mutate(&body)
221+
222+
status, got, problem := c.Create<X>(body)
223+
assert.Equal(t, tc.expectedStatus, status, "problem: %+v", problem)
224+
225+
switch {
226+
case tc.expectedCode != "":
227+
assertValidationCode(t, problem, tc.expectedCode)
228+
case tc.expectedDetailIn != "":
229+
assertProblemDetail(t, problem, tc.expectedDetailIn)
230+
default:
231+
require.NotNil(t, got)
232+
}
233+
})
234+
}
235+
}
236+
```
237+
238+
### Eventual consistency (v1 ingestion flow)
239+
240+
Kafka is in the path for ingestion. Don't assert the meter value immediately after ingest — wrap the read in `assert.EventuallyWithT` with a reasonable ceiling.
241+
242+
Reference: `e2e/e2e_test.go` `TestIngest`.
243+
244+
## Testing conventions
245+
246+
- **`require` vs `assert`**: `require` for fatal preconditions (no point continuing), `assert` for soft per-field checks. In table rows, use `assert.Equal(t, tc.expectedStatus, status, "%+v", problem)` for the status check so the subsequent body-shape assertion still fires and surfaces in the same failure. Reserve `require` for lifecycle tests where later steps depend on the earlier status being correct.
247+
- **`t.Helper()`** in every helper function — so `require` failures blame the caller.
248+
- **`t.Context()`** over `context.Background()` — cancellation ties to the test.
249+
- **Test naming**: when both v1 and v3 tests live in the same package, prefix v3 tests with `TestV3` to disambiguate (`TestV3PlanLifecycle`, `TestV3AddonVersioningAndAutoArchive`). For single-style packages, the `V3` prefix is unnecessary.
250+
- **Client lifetime**: one `newV3Client(t)` at the top for lifecycle tests (shared state); one per `t.Run` for table-driven validation (independent rows).
251+
- **Parallelism**: the current suite does not opt in to `t.Parallel()`. Fixtures are unique-keyed, so it's safe in principle — but the shared DB means intermittent list-ordering flakiness is possible. Opt in deliberately, row by row, not globally.
252+
253+
## Gotchas worth knowing before you write a new v3 test
254+
255+
Captured from real live-server runs. Most are v3-wide; a few call out plans/addons specifically because they're the only v3 surface today that exposes drafts.
256+
257+
- **Deep-object query params** like `?page[number]=1&page[size]=20` are encoded by `url.Values.Encode()` with percent-encoded brackets; the server decodes them back. Both forms work.
258+
- **Some delete paths return 400 `"plan is deleted"` rather than 404** for entities in the deleted state. Don't assume 404 by default.
259+
260+
## Further reading
261+
262+
- **`AGENTS.md`** — repo-wide conventions: toolchain fallback, build tag, `POSTGRES_HOST` for in-process tests, general coding rules.
263+
- **Generated v3 types**`api/v3/api.gen.go` (regenerated by `make gen-api`; don't edit). `BillingPrice` and similar discriminated unions require the `FromBillingPriceXxx` helpers — never build the raw struct by hand.

api/v3/handlers/plans/convert.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -407,12 +407,12 @@ func FromAPICreatePlanRequest(ns string, body api.CreatePlanRequest) (plan.Creat
407407
},
408408
}
409409

410-
cur := currency.Code(body.Currency)
411-
if err := cur.Validate(); err != nil {
412-
return req, fmt.Errorf("invalid currency: %w", err)
413-
}
414-
415-
req.Currency = cur
410+
// Currency is deliberately not validated here — the service layer's
411+
// PlanMeta.Validate() produces a structured ValidationIssue
412+
// (ErrCurrencyInvalid, critical severity + 400 HTTP attribute) so an
413+
// unknown currency surfaces as 400 with extensions.validationErrors[].code
414+
// instead of a generic 500 from a handler-level fmt.Errorf.
415+
req.Currency = currency.Code(body.Currency)
416416

417417
billingCadence, err := datetime.ISODurationString(body.BillingCadence).Parse()
418418
if err != nil {

api/v3/handlers/plans/convert_test.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -830,19 +830,6 @@ func TestToCreatePlanInput(t *testing.T) {
830830
assert.Nil(t, result.Metadata)
831831
})
832832

833-
t.Run("invalid currency returns error", func(t *testing.T) {
834-
body := api.CreatePlanRequest{
835-
Key: "pro",
836-
Name: "Pro",
837-
Currency: "INVALID",
838-
BillingCadence: "P1M",
839-
}
840-
841-
_, err := FromAPICreatePlanRequest("ns", body)
842-
require.Error(t, err)
843-
assert.Contains(t, err.Error(), "invalid currency")
844-
})
845-
846833
t.Run("invalid billing cadence returns error", func(t *testing.T) {
847834
body := api.CreatePlanRequest{
848835
Key: "pro",

0 commit comments

Comments
 (0)