|
| 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. |
0 commit comments