From a7d1786610aaaf9c05d8b5a567af462e05612302 Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Wed, 29 Apr 2026 12:42:55 +0200 Subject: [PATCH 1/7] chore: add playwright e2e api test example --- .agents/skills/e2e-playwright/SKILL.md | 336 ++++++++++++++++++ Makefile | 5 + e2e/playwright/.gitignore | 5 + e2e/playwright/package.json | 14 + e2e/playwright/playwright.config.ts | 19 + e2e/playwright/pnpm-lock.yaml | 71 ++++ .../tests/currencies/create-and-list.spec.ts | 97 +++++ 7 files changed, 547 insertions(+) create mode 100644 .agents/skills/e2e-playwright/SKILL.md create mode 100644 e2e/playwright/.gitignore create mode 100644 e2e/playwright/package.json create mode 100644 e2e/playwright/playwright.config.ts create mode 100644 e2e/playwright/pnpm-lock.yaml create mode 100644 e2e/playwright/tests/currencies/create-and-list.spec.ts diff --git a/.agents/skills/e2e-playwright/SKILL.md b/.agents/skills/e2e-playwright/SKILL.md new file mode 100644 index 0000000000..d7d6eeb0ae --- /dev/null +++ b/.agents/skills/e2e-playwright/SKILL.md @@ -0,0 +1,336 @@ +--- +name: e2e-playwright +description: Generate API tests using Playwright against OpenMeter's v3 API. Use when creating TypeScript-based API tests that exercise HTTP endpoints over a live server with configurable base URL and optional API key auth. +user-invocable: true +argument-hint: "[domain to test] [user journey description]" +allowed-tools: Read, Edit, Write, Bash, Grep, Glob, Agent +--- + +# Playwright API Testing + +You are helping the user write Playwright API tests for OpenMeter's v3 API. These are TypeScript tests using Playwright's `request` context — no browser required, purely exercising the HTTP contract. + +**Before writing tests**, read the relevant sections of `api/v3/openapi.yaml` to learn the exact request/response shapes, required fields, status codes, and error schemas for the endpoints you'll exercise. + +## Project Layout + +All Playwright tests live under `e2e/playwright/`: + +``` +e2e/playwright/ +├── playwright.config.ts # Base URL, auth headers, timeout config +├── helpers.ts # Shared test utilities (uniqueKey, etc.) +├── tests/ +│ └── / +│ └── .spec.ts # One file per user journey +└── package.json +``` + +If `e2e/playwright/` doesn't exist yet, create it with the scaffolding below before writing the test. + +## Scaffolding + +### package.json + +```json +{ + "name": "openmeter-e2e-playwright", + "private": true, + "packageManager": "pnpm@10.33.0", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed" + }, + "devDependencies": { + "@faker-js/faker": "^10.0.0", + "@playwright/test": "^1.44.0", + "typescript": "^5.4.0" + } +} +``` + +### playwright.config.ts + +```typescript +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + retries: 0, + reporter: 'list', + use: { + baseURL: process.env.OPENMETER_ADDRESS ?? 'http://localhost:8888', + extraHTTPHeaders: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...(process.env.OPENMETER_API_KEY + ? { Authorization: `Bearer ${process.env.OPENMETER_API_KEY}` } + : {}), + }, + ignoreHTTPSErrors: true, + }, +}) +``` + + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `OPENMETER_ADDRESS` | `http://localhost:8888` | Server base URL | +| `OPENMETER_API_KEY` | _(unset)_ | Sent as `Authorization: Bearer ` when set | + +Run tests: + +```bash +# Against local dev server (no auth) +cd e2e/playwright && pnpm playwright test + +# Against a remote server with auth +OPENMETER_ADDRESS=https://openmeter.cloud OPENMETER_API_KEY=om_key_xxx pnpm playwright test + +# Single file +pnpm playwright test tests/customers/create-and-subscribe.spec.ts +``` + +## API Reference + +The canonical reference is `api/v3/openapi.yaml`. Read the relevant `paths:` entries before writing a test: + +- All v3 endpoints are under `/api/v3/openmeter/…` (the `servers[0].url` is `http://localhost:{port}/api/v3`, so paths in the spec are relative to that — prepend `/api/v3/openmeter` for the raw fetch). +- Response shapes are in `components/schemas/`. +- Error responses follow RFC 7807 (`application/problem+json`). On 4xx/5xx, parse with `await response.json()` and inspect `type`, `title`, `detail`, `extensions.validationErrors`, or `invalid_parameters`. +- Required vs optional fields are marked in each schema. Pay attention — missing required fields often produce 400 schema errors, not domain errors. + +### Finding the right endpoint + +**Step 1 — Read the TypeSpec operations file for the domain.** + +These files are the source of truth before OpenAPI generation. Each is short, domain-isolated, and shows HTTP verb, `@operationId`, path parameters, and request/response type names at a glance — far easier to scan than the 9307-line openapi.yaml. + +``` +api/spec/packages/aip/src/customers/operations.tsp +api/spec/packages/aip/src/subscriptions/operations.tsp +api/spec/packages/aip/src/billing/operations.tsp +api/spec/packages/aip/src/meters/operations.tsp +api/spec/packages/aip/src//operations.tsp # one per domain +``` + +To list all available domains, use `codegraph_files` on `api/spec/packages/aip/src` (or `Glob` if CodeGraph is unavailable). + +**Step 2 — Look up exact schema details in `api/v3/openapi.yaml`.** + +Once you know the type names from the TypeSpec file, find the precise field names, required/optional markers, and enum values in `components/schemas/`. Use the `Grep` tool (not shell grep) to jump straight to a schema: + +``` +Grep "CustomerCreateInput" in api/v3/openapi.yaml +``` + +## Writing Tests + +### File naming + +One file per user journey under `tests//`. Use kebab-case: + +``` +tests/customers/create-and-list.spec.ts +tests/subscriptions/subscribe-and-cancel.spec.ts +tests/billing/invoice-lifecycle.spec.ts +``` + +### Test structure + +Import directly from `@playwright/test` and use the `request` fixture. Define a `BASE` constant for the API path prefix. Use `faker.string.uuid()` for unique string fields: + +```typescript +import { test, expect } from '@playwright/test' +import { faker } from '@faker-js/faker' + +const BASE = '/api/v3/openmeter' + +test.describe('Customer > create and list', () => { + test('creates a customer and finds it in the list', async ({ request }) => { + const key = faker.string.uuid() + + // Create + const createRes = await request.post(`${BASE}/customers`, { + data: { name: 'Test Customer', key }, + }) + expect(createRes.status()).toBe(201) + const customer = await createRes.json() + expect(customer.key).toBe(key) + const id: string = customer.id + + // List — bump page size so the new row is on page 1 + const listRes = await request.get(`${BASE}/customers`, { + params: { 'page[size]': '1000' }, + }) + expect(listRes.status()).toBe(200) + const { items } = await listRes.json() + const found = items.find((c: { id: string }) => c.id === id) + expect(found).toBeDefined() + }) +}) +``` + +### Lifecycle tests (ordered steps sharing state) + +When the journey is "create → update → publish → archive → delete", use sequential `test` blocks inside a single `describe` block. Share state through the outer scope: + +```typescript +import { test, expect } from '@playwright/test' +import { faker } from '@faker-js/faker' + +const BASE = '/api/v3/openmeter' + +test.describe('Plan > full lifecycle', () => { + let planId: string + + test('create draft plan', async ({ request }) => { + const res = await request.post(`${BASE}/plans`, { data: validPlanBody(faker.string.uuid()) }) + expect(res.status()).toBe(201) + const plan = await res.json() + expect(plan.status).toBe('draft') + planId = plan.id + }) + + test('publish plan', async ({ request }) => { + expect(planId).toBeTruthy() + const res = await request.post(`${BASE}/plans/${planId}/publish`, { data: {} }) + expect(res.status()).toBe(200) + const plan = await res.json() + expect(plan.status).toBe('active') + }) + + test('archive plan', async ({ request }) => { + expect(planId).toBeTruthy() + const res = await request.post(`${BASE}/plans/${planId}/archive`, { data: {} }) + expect(res.status()).toBe(200) + const plan = await res.json() + expect(plan.status).toBe('archived') + }) +}) +``` + +> Lifecycle subtests run in declaration order. If one step fails, later steps that depend on `planId` will also fail — this is intentional, not a problem. + +### Table-driven validation (independent cases) + +For input-validation matrices, use a loop over cases. Each row gets a fresh request context: + +```typescript +import { test, expect } from '@playwright/test' +import { faker } from '@faker-js/faker' + +const BASE = '/api/v3/openmeter' + +const invalidBodies = [ + { label: 'missing name', body: { key: 'k1' }, expectedStatus: 400 }, + { label: 'empty key', body: { name: 'N', key: '' }, expectedStatus: 400 }, +] + +for (const { label, body, expectedStatus } of invalidBodies) { + test(`rejects ${label}`, async ({ request }) => { + const res = await request.post(`${BASE}/customers`, { data: body }) + expect(res.status()).toBe(expectedStatus) + const problem = await res.json() + expect(problem.type).toBeDefined() + }) +} +``` + +### Asserting error shapes + +v3 returns three error shapes. Parse the body and pick the right assertion: + +```typescript +const problem = await res.json() + +// 1. Domain validation (extensions.validationErrors[]) +// Produced by handlers that return models.ValidationIssue +const codes = (problem.extensions?.validationErrors ?? []).map((e: any) => e.code) +expect(codes).toContain('plan_phase_duplicated_key') + +// 2. Free-text Detail (BaseAPIError) +// Produced by apierrors.NewBadRequestError with a plain message +expect(problem.detail).toContain('only Plans in [draft scheduled] can be updated') + +// 3. Schema / binder errors (invalid_parameters[]) +// Produced before any handler runs (bad enum, missing required field) +const rules = (problem.invalid_parameters ?? []).map((p: any) => p.rule) +expect(rules).toContain('required') +``` + +> Tip: if unsure which shape applies, `console.log(await res.json())` on a failing test — the shape tells you which assertion to use. + +### Unique keys and collision avoidance + +The server DB persists across test re-runs. Always generate unique fixture data with faker: + +```typescript +import { faker } from '@faker-js/faker' + +const key = faker.string.uuid() // "550e8400-e29b-41d4-a716-446655440000" +``` + +Never hardcode a value that could collide with a previous run or a parallel worker. + +### Eventual consistency (events / ingestion) + +If the journey includes ingesting usage events, the processing is async through Kafka. Poll for the expected result: + +```typescript +import { test, expect } from '@playwright/test' + +const BASE = '/api/v3/openmeter' + +test('meter value reflects ingested events', async ({ request }) => { + // ... ingest ... + + // Poll until the meter reflects the event (up to 10s) + await expect.poll( + async () => { + const res = await request.get(`${BASE}/meters/${meterSlug}/query`, { + params: { subject: customerId }, + }) + expect(res.status()).toBe(200) + const { data } = await res.json() + return data[0]?.value ?? 0 + }, + { timeout: 10_000, intervals: [500, 1000, 2000] }, + ).toBeGreaterThan(0) +}) +``` + +## Conventions + +- **Import from `@playwright/test`** directly — no custom fixture layer. +- **Define `const BASE = '/api/v3/openmeter'`** at the top of each test file for the path prefix. +- **Use `faker.string.uuid()`** for any unique string field (names, keys, slugs). Never hardcode. +- **Read `api/v3/openapi.yaml`** for the endpoint before writing the request. Wrong field names produce 400s that look like test bugs. +- **Page size**: when listing to find a freshly-created entity, pass `'page[size]': '1000'` to avoid pagination miss on a shared DB. +- **Decimal round-trip**: the server trims trailing zeros (`"0.10"` → `"0.1"`). Compare the normalized form or parse as number. +- **Draft lifecycle**: some resources (plans, addons) accept invalid drafts and only reject at publish. If a create returns 201 unexpectedly, check the response body for `validation_errors` and pivot to the draft-with-errors assertion path. + +## Running & Debugging + +```bash +cd e2e/playwright + +# Install dependencies (first time) +pnpm install +pnpm playwright install + +# Run all tests +pnpm playwright test + +# Run a specific file +pnpm playwright test tests/customers/ + +# Show full request/response on failure +DEBUG=pw:api pnpm playwright test + +# With env overrides +OPENMETER_ADDRESS=http://localhost:8888 OPENMETER_API_KEY=om_key_xxx pnpm playwright test +``` diff --git a/Makefile b/Makefile index c2617edbb0..ab8c442f88 100644 --- a/Makefile +++ b/Makefile @@ -209,6 +209,11 @@ etoe: ## Run e2e tests $(call print-target) $(MAKE) -C e2e test-local +.PHONY: etoe-pw +etoe-pw: ## Run Playwright e2e tests + $(call print-target) + cd e2e/playwright && pnpm playwright test + .PHONY: etoe-slow etoe-slow: ## Run e2e tests with slow tests enabled $(call print-target) diff --git a/e2e/playwright/.gitignore b/e2e/playwright/.gitignore new file mode 100644 index 0000000000..1f95add878 --- /dev/null +++ b/e2e/playwright/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ diff --git a/e2e/playwright/package.json b/e2e/playwright/package.json new file mode 100644 index 0000000000..1a005e6827 --- /dev/null +++ b/e2e/playwright/package.json @@ -0,0 +1,14 @@ +{ + "name": "openmeter-e2e-playwright", + "private": true, + "packageManager": "pnpm@10.33.0", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed" + }, + "devDependencies": { + "@faker-js/faker": "^10.4.0", + "@playwright/test": "^1.44.0", + "typescript": "^5.4.0" + } +} diff --git a/e2e/playwright/playwright.config.ts b/e2e/playwright/playwright.config.ts new file mode 100644 index 0000000000..1dca5cb3b8 --- /dev/null +++ b/e2e/playwright/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + retries: 0, + reporter: 'list', + use: { + baseURL: process.env.OPENMETER_ADDRESS ?? 'http://localhost:8888', + extraHTTPHeaders: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...(process.env.OPENMETER_API_KEY + ? { Authorization: `Bearer ${process.env.OPENMETER_API_KEY}` } + : {}), + }, + ignoreHTTPSErrors: true, + }, +}) diff --git a/e2e/playwright/pnpm-lock.yaml b/e2e/playwright/pnpm-lock.yaml new file mode 100644 index 0000000000..b5d3025fe2 --- /dev/null +++ b/e2e/playwright/pnpm-lock.yaml @@ -0,0 +1,71 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@faker-js/faker': + specifier: ^10.4.0 + version: 10.4.0 + '@playwright/test': + specifier: ^1.44.0 + version: 1.59.1 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + +packages: + + '@faker-js/faker@10.4.0': + resolution: {integrity: sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==} + engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + '@faker-js/faker@10.4.0': {} + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + typescript@5.9.3: {} diff --git a/e2e/playwright/tests/currencies/create-and-list.spec.ts b/e2e/playwright/tests/currencies/create-and-list.spec.ts new file mode 100644 index 0000000000..7fd2192531 --- /dev/null +++ b/e2e/playwright/tests/currencies/create-and-list.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from '@playwright/test' +import { faker } from '@faker-js/faker' + +const BASE = '/api/v3/openmeter' + +// Currency codes must be 3-24 chars and not conflict with fiat codes (USD, EUR, …) +function uniqueCurrencyCode(): string { + return `TST${faker.string.alphanumeric({ length: 5, casing: 'upper' })}` +} + +test.describe('Currencies > create and list', () => { + test('creates a custom currency and finds it in the list', async ({ request }) => { + const code = uniqueCurrencyCode() + const name = faker.string.uuid() + + // Create + const createRes = await request.post(`${BASE}/currencies/custom`, { + data: { code, name, description: 'A custom currency for testing', symbol: '¤' }, + }) + expect(createRes.status()).toBe(201) + const currency = await createRes.json() + expect(currency.type).toBe('custom') + expect(currency.code).toBe(code) + expect(currency.name).toBe(name) + expect(currency.id).toBeTruthy() + + // List all currencies and find the created one + const listRes = await request.get(`${BASE}/currencies`, { + params: { 'page[size]': '1000' }, + }) + expect(listRes.status()).toBe(200) + const { data } = await listRes.json() + const found = data.find((c: { id: string }) => c.id === currency.id) + expect(found).toBeDefined() + expect(found.type).toBe('custom') + expect(found.code).toBe(code) + }) + + test('lists only custom currencies when filter[type]=custom', async ({ request }) => { + const code = uniqueCurrencyCode() + const name = faker.string.uuid() + + // Create a custom currency to ensure at least one exists + const createRes = await request.post(`${BASE}/currencies/custom`, { + data: { code, name, symbol: '¤' }, + }) + expect(createRes.status()).toBe(201) + const currency = await createRes.json() + + // List filtered to custom only + const listRes = await request.get(`${BASE}/currencies`, { + params: { 'page[size]': '1000', 'filter[type]': 'custom' }, + }) + expect(listRes.status()).toBe(200) + const { data } = await listRes.json() + + // Every item must be a custom currency + for (const item of data) { + expect(item.type).toBe('custom') + } + + // The one we created must appear + const found = data.find((c: { id: string }) => c.id === currency.id) + expect(found).toBeDefined() + }) + + test('lists only fiat currencies when filter[type]=fiat', async ({ request }) => { + const listRes = await request.get(`${BASE}/currencies`, { + params: { 'page[size]': '1000', 'filter[type]': 'fiat' }, + }) + expect(listRes.status()).toBe(200) + const { data } = await listRes.json() + expect(data.length).toBeGreaterThan(0) + + for (const item of data) { + expect(item.type).toBe('fiat') + } + }) + + test('rejects create with missing required name', async ({ request }) => { + const res = await request.post(`${BASE}/currencies/custom`, { + data: { code: uniqueCurrencyCode(), symbol: '¤' }, + }) + expect(res.status()).toBe(400) + const problem = await res.json() + expect(problem.type).toBeDefined() + }) + + test('rejects create with missing required code', async ({ request }) => { + const res = await request.post(`${BASE}/currencies/custom`, { + data: { name: faker.string.uuid(), symbol: '¤' }, + }) + expect(res.status()).toBe(400) + const problem = await res.json() + expect(problem.type).toBeDefined() + }) +}) From 2855a0874febffa033a60d0f06b4ef309c1445f0 Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Wed, 29 Apr 2026 12:57:43 +0200 Subject: [PATCH 2/7] chore: add ci job --- .github/workflows/ci.yaml | 86 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9d3836a9fd..2b954a3485 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: permissions: contents: read @@ -819,3 +820,88 @@ jobs: e2e/logs/openmeter/openmeter.log e2e/logs/sink-worker/openmeter.log retention-days: 14 + + e2e-playwright: + name: E2E Playwright + runs-on: depot-ubuntu-latest-8 + needs: + - trusted-artifacts + - untrusted-artifacts + if: ${{ github.event_name == 'workflow_dispatch' && !cancelled() && !contains(needs.*.result, 'failure') && contains(needs.*.result, 'success') }} + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Create override files for e2e + env: + DEPOT_IMAGE_URL: ${{ needs.trusted-artifacts.outputs.container-image-url-depot }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + run: | + cat > e2e/docker-compose.override.yaml < e2e/docker-compose.override.yaml < Date: Wed, 29 Apr 2026 13:07:01 +0200 Subject: [PATCH 3/7] fix: trigger e2e --- .github/workflows/ci.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b954a3485..493476ca17 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - workflow_dispatch: permissions: contents: read @@ -827,7 +826,7 @@ jobs: needs: - trusted-artifacts - untrusted-artifacts - if: ${{ github.event_name == 'workflow_dispatch' && !cancelled() && !contains(needs.*.result, 'failure') && contains(needs.*.result, 'success') }} + if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && contains(needs.*.result, 'success') }} steps: - name: Checkout repository From cd02a34b4526f38c37971e80baf70e8dfb97f65d Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Wed, 29 Apr 2026 13:54:18 +0200 Subject: [PATCH 4/7] chore: extend skill to generate more like contract testing --- .agents/skills/e2e-playwright/SKILL.md | 2 +- .../tests/currencies/create-and-list.spec.ts | 46 +++++++------------ 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/.agents/skills/e2e-playwright/SKILL.md b/.agents/skills/e2e-playwright/SKILL.md index d7d6eeb0ae..19bf2897f6 100644 --- a/.agents/skills/e2e-playwright/SKILL.md +++ b/.agents/skills/e2e-playwright/SKILL.md @@ -1,6 +1,6 @@ --- name: e2e-playwright -description: Generate API tests using Playwright against OpenMeter's v3 API. Use when creating TypeScript-based API tests that exercise HTTP endpoints over a live server with configurable base URL and optional API key auth. +description: Generate API tests using Playwright against OpenMeter's v3 API. Use when creating TypeScript-based API tests that exercise HTTP endpoints over a live server with configurable base URL and optional API key auth. Tests produced by this skill are suitable for contract testing — they verify the HTTP contract (status codes, request/response shapes, required fields, error schemas) as defined in the OpenAPI spec. user-invocable: true argument-hint: "[domain to test] [user journey description]" allowed-tools: Read, Edit, Write, Bash, Grep, Glob, Agent diff --git a/e2e/playwright/tests/currencies/create-and-list.spec.ts b/e2e/playwright/tests/currencies/create-and-list.spec.ts index 7fd2192531..cf0fc72ff3 100644 --- a/e2e/playwright/tests/currencies/create-and-list.spec.ts +++ b/e2e/playwright/tests/currencies/create-and-list.spec.ts @@ -13,18 +13,18 @@ test.describe('Currencies > create and list', () => { const code = uniqueCurrencyCode() const name = faker.string.uuid() - // Create const createRes = await request.post(`${BASE}/currencies/custom`, { + // symbol is required server-side despite being optional in the OpenAPI spec data: { code, name, description: 'A custom currency for testing', symbol: '¤' }, }) expect(createRes.status()).toBe(201) const currency = await createRes.json() + expect(currency.id).toBeTruthy() expect(currency.type).toBe('custom') expect(currency.code).toBe(code) expect(currency.name).toBe(name) - expect(currency.id).toBeTruthy() + expect(currency.created_at).toBeTruthy() - // List all currencies and find the created one const listRes = await request.get(`${BASE}/currencies`, { params: { 'page[size]': '1000' }, }) @@ -37,29 +37,21 @@ test.describe('Currencies > create and list', () => { }) test('lists only custom currencies when filter[type]=custom', async ({ request }) => { - const code = uniqueCurrencyCode() - const name = faker.string.uuid() - - // Create a custom currency to ensure at least one exists const createRes = await request.post(`${BASE}/currencies/custom`, { - data: { code, name, symbol: '¤' }, + // symbol is required server-side despite being optional in the OpenAPI spec + data: { code: uniqueCurrencyCode(), name: faker.string.uuid(), symbol: '¤' }, }) expect(createRes.status()).toBe(201) const currency = await createRes.json() - // List filtered to custom only const listRes = await request.get(`${BASE}/currencies`, { params: { 'page[size]': '1000', 'filter[type]': 'custom' }, }) expect(listRes.status()).toBe(200) const { data } = await listRes.json() - - // Every item must be a custom currency for (const item of data) { expect(item.type).toBe('custom') } - - // The one we created must appear const found = data.find((c: { id: string }) => c.id === currency.id) expect(found).toBeDefined() }) @@ -71,27 +63,21 @@ test.describe('Currencies > create and list', () => { expect(listRes.status()).toBe(200) const { data } = await listRes.json() expect(data.length).toBeGreaterThan(0) - for (const item of data) { expect(item.type).toBe('fiat') } }) - test('rejects create with missing required name', async ({ request }) => { - const res = await request.post(`${BASE}/currencies/custom`, { - data: { code: uniqueCurrencyCode(), symbol: '¤' }, - }) - expect(res.status()).toBe(400) - const problem = await res.json() - expect(problem.type).toBeDefined() - }) - - test('rejects create with missing required code', async ({ request }) => { - const res = await request.post(`${BASE}/currencies/custom`, { - data: { name: faker.string.uuid(), symbol: '¤' }, + for (const { label, buildBody } of [ + { label: 'missing required name', buildBody: () => ({ code: uniqueCurrencyCode(), symbol: '¤' }) }, + { label: 'missing required code', buildBody: () => ({ name: faker.string.uuid(), symbol: '¤' }) }, + ]) { + test(`rejects create with ${label}`, async ({ request }) => { + const res = await request.post(`${BASE}/currencies/custom`, { data: buildBody() }) + expect(res.status()).toBe(400) + const problem = await res.json() + const rules = (problem.invalid_parameters ?? []).map((p: any) => p.rule) + expect(rules).toContain('required') }) - expect(res.status()).toBe(400) - const problem = await res.json() - expect(problem.type).toBeDefined() - }) + } }) From 5d5e4e81a0d566f83050f8f0dbc670892d65cea7 Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Wed, 6 May 2026 16:04:19 +0200 Subject: [PATCH 5/7] chore(e2e): add playwright smoke tests for plan/addon lifecycle and quickstart Adds two journey-style Playwright API tests covering the v3 product catalog flow (meter + feature + draft plan with mixed rate cards, invalid-card rejection, addon attach, publish) and the quickstart CloudEvents ingest + meter query flow. Provisioning helpers extracted to helpers/catalog.ts. --- e2e/playwright/helpers/catalog.ts | 98 ++++++++ .../plan-addon-lifecycle.spec.ts | 224 ++++++++++++++++++ .../tests/quickstart/ingest-and-query.spec.ts | 70 ++++++ 3 files changed, 392 insertions(+) create mode 100644 e2e/playwright/helpers/catalog.ts create mode 100644 e2e/playwright/tests/productcatalog/plan-addon-lifecycle.spec.ts create mode 100644 e2e/playwright/tests/quickstart/ingest-and-query.spec.ts diff --git a/e2e/playwright/helpers/catalog.ts b/e2e/playwright/helpers/catalog.ts new file mode 100644 index 0000000000..862228bede --- /dev/null +++ b/e2e/playwright/helpers/catalog.ts @@ -0,0 +1,98 @@ +import type { APIRequestContext } from '@playwright/test' +import { expect } from '@playwright/test' +import { faker } from '@faker-js/faker' + +export const BASE = '/api/v3/openmeter' +export const V1_BASE = '/api/v1' + +export type CreateMeterOverrides = { + key?: string + name?: string + aggregation?: 'sum' | 'count' | 'unique_count' | 'avg' | 'min' | 'max' | 'latest' + event_type?: string + value_property?: string + description?: string +} + +export type Meter = { + id: string + key: string +} + +export async function createMeter( + request: APIRequestContext, + overrides: CreateMeterOverrides = {}, +): Promise { + const body = { + key: overrides.key ?? `meter_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + name: overrides.name ?? 'Test Meter', + aggregation: overrides.aggregation ?? 'sum', + event_type: overrides.event_type ?? 'request', + value_property: overrides.value_property ?? '$.duration_ms', + ...(overrides.description ? { description: overrides.description } : {}), + } + + const res = await request.post(`${BASE}/meters`, { data: body }) + expect(res.status(), `meter create failed: ${await res.text()}`).toBe(201) + const meter = await res.json() + return { id: meter.id, key: meter.key } +} + +export type CreateFeatureOverrides = { + key?: string + name?: string + meterId?: string +} + +export type Feature = { + id: string + key: string +} + +export async function createFeature( + request: APIRequestContext, + overrides: CreateFeatureOverrides = {}, +): Promise { + const body: Record = { + key: overrides.key ?? `feature_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + name: overrides.name ?? 'Test Feature', + } + if (overrides.meterId) { + body.meter = { id: overrides.meterId } + } + + const res = await request.post(`${BASE}/features`, { data: body }) + expect(res.status(), `feature create failed: ${await res.text()}`).toBe(201) + const feature = await res.json() + return { id: feature.id, key: feature.key } +} + +export type CloudEvent = { + id: string + source: string + type: string + subject: string + time?: string + data?: Record +} + +export async function ingestEvent( + request: APIRequestContext, + event: CloudEvent, +): Promise { + const body = { + specversion: '1.0', + id: event.id, + source: event.source, + type: event.type, + subject: event.subject, + ...(event.time ? { time: event.time } : {}), + ...(event.data ? { data: event.data } : {}), + } + + const res = await request.post(`${V1_BASE}/events`, { + headers: { 'Content-Type': 'application/cloudevents+json' }, + data: body, + }) + expect(res.status(), `ingest failed: ${await res.text()}`).toBe(204) +} diff --git a/e2e/playwright/tests/productcatalog/plan-addon-lifecycle.spec.ts b/e2e/playwright/tests/productcatalog/plan-addon-lifecycle.spec.ts new file mode 100644 index 0000000000..5ea9e19bd4 --- /dev/null +++ b/e2e/playwright/tests/productcatalog/plan-addon-lifecycle.spec.ts @@ -0,0 +1,224 @@ +import { test, expect } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { BASE, createMeter, createFeature } from '../../helpers/catalog' + +test.describe.configure({ mode: 'serial' }) + +test.describe('Plan + addon > smoke lifecycle', () => { + let meterId: string + let featureId: string + let planId: string + let addonId: string + let planAddonId: string + let planName: string + let phaseKey: string + + // Rate card keys reused across mutation steps so we can identify them in + // responses and remove the invalid one in step 5. + const flatKey = `flat_fee_${faker.string.alphanumeric({ length: 6, casing: 'lower' })}` + const usageKey = `usage_unit_${faker.string.alphanumeric({ length: 6, casing: 'lower' })}` + const graduatedKey = `tiered_graduated_${faker.string.alphanumeric({ length: 6, casing: 'lower' })}` + + function flatRateCard(key: string) { + return { + key, + name: 'Monthly Fee', + price: { type: 'flat', amount: '10' }, + billing_cadence: 'P1M', + payment_term: 'in_advance', + } + } + + function unitRateCard(key: string, featureRefId: string) { + return { + key, + name: 'Usage Unit', + feature: { id: featureRefId }, + price: { type: 'unit', amount: '0.10' }, + billing_cadence: 'P1M', + payment_term: 'in_arrears', + } + } + + function graduatedRateCard(key: string) { + return { + key, + name: 'Tiered Graduated', + price: { + type: 'graduated', + tiers: [ + { up_to_amount: '100', unit_price: { type: 'unit', amount: '0.10' } }, + { unit_price: { type: 'unit', amount: '0.05' } }, + ], + }, + billing_cadence: 'P1M', + payment_term: 'in_arrears', + } + } + + // Invalid because the key duplicates an existing rate card in the same phase. + // Duplicate rate-card keys are rejected at create/update time with HTTP 400. + function duplicateKeyRateCard(duplicateOf: string) { + return { + key: duplicateOf, + name: 'Duplicate Key Card', + price: { type: 'flat', amount: '99' }, + billing_cadence: 'P1M', + payment_term: 'in_advance', + } + } + + test('provisions a meter and feature', async ({ request }) => { + const meter = await createMeter(request) + expect(meter.id).toBeTruthy() + meterId = meter.id + + const feature = await createFeature(request, { meterId }) + expect(feature.id).toBeTruthy() + featureId = feature.id + }) + + test('creates a draft plan with an initial flat rate card', async ({ request }) => { + expect(meterId).toBeTruthy() + + planName = `Smoke Plan ${faker.string.alphanumeric({ length: 6, casing: 'lower' })}` + phaseKey = `phase_${faker.string.alphanumeric({ length: 6, casing: 'lower' })}` + + const body = { + key: `plan_${faker.string.alphanumeric({ length: 12, casing: 'lower' })}`, + name: planName, + currency: 'USD', + billing_cadence: 'P1M', + phases: [ + { + key: phaseKey, + name: 'Phase 1', + rate_cards: [flatRateCard(flatKey)], + }, + ], + } + + const res = await request.post(`${BASE}/plans`, { data: body }) + expect(res.status(), `plan create failed: ${await res.text()}`).toBe(201) + const plan = await res.json() + expect(plan.status).toBe('draft') + expect(plan.phases[0].rate_cards).toHaveLength(1) + planId = plan.id + }) + + test('adds usage and graduated rate cards via update', async ({ request }) => { + expect(planId).toBeTruthy() + + const body = { + name: planName, + phases: [ + { + key: phaseKey, + name: 'Phase 1', + rate_cards: [ + flatRateCard(flatKey), + unitRateCard(usageKey, featureId), + graduatedRateCard(graduatedKey), + ], + }, + ], + } + + const res = await request.put(`${BASE}/plans/${planId}`, { data: body }) + expect(res.status(), `plan update failed: ${await res.text()}`).toBe(200) + const plan = await res.json() + expect(plan.phases[0].rate_cards).toHaveLength(3) + const keys = plan.phases[0].rate_cards.map((rc: { key: string }) => rc.key).sort() + expect(keys).toEqual([flatKey, graduatedKey, usageKey].sort()) + }) + + test('rejects adding an invalid rate card', async ({ request }) => { + expect(planId).toBeTruthy() + + const body = { + name: planName, + phases: [ + { + key: phaseKey, + name: 'Phase 1', + rate_cards: [ + flatRateCard(flatKey), + unitRateCard(usageKey, featureId), + graduatedRateCard(graduatedKey), + duplicateKeyRateCard(flatKey), + ], + }, + ], + } + + const res = await request.put(`${BASE}/plans/${planId}`, { data: body }) + expect(res.status()).toBe(400) + const problem = await res.json() + expect(problem.title).toBe('Bad Request') + expect((problem.detail ?? '').toLowerCase()).toContain('duplicat') + }) + + test('confirms the invalid card was not added', async ({ request }) => { + expect(planId).toBeTruthy() + + const res = await request.get(`${BASE}/plans/${planId}`) + expect(res.status(), `plan get failed: ${await res.text()}`).toBe(200) + const plan = await res.json() + expect(plan.phases[0].rate_cards).toHaveLength(3) + const keys = plan.phases[0].rate_cards.map((rc: { key: string }) => rc.key).sort() + expect(keys).toEqual([flatKey, graduatedKey, usageKey].sort()) + }) + + test('creates a draft addon', async ({ request }) => { + const body = { + key: `addon_${faker.string.alphanumeric({ length: 12, casing: 'lower' })}`, + name: `Smoke Addon ${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`, + currency: 'USD', + instance_type: 'single', + rate_cards: [flatRateCard(`addon_fee_${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`)], + } + + const res = await request.post(`${BASE}/addons`, { data: body }) + expect(res.status(), `addon create failed: ${await res.text()}`).toBe(201) + const addon = await res.json() + expect(addon.status).toBe('draft') + addonId = addon.id + }) + + test('publishes the addon', async ({ request }) => { + expect(addonId).toBeTruthy() + const res = await request.post(`${BASE}/addons/${addonId}/publish`) + expect(res.status(), `addon publish failed: ${await res.text()}`).toBe(200) + const addon = await res.json() + expect(addon.status).toBe('active') + expect(addon.effective_from).toBeTruthy() + }) + + test('attaches the addon to the plan', async ({ request }) => { + expect(planId).toBeTruthy() + expect(addonId).toBeTruthy() + + const body = { + name: 'Smoke Plan Addon', + addon: { id: addonId }, + from_plan_phase: phaseKey, + } + + const res = await request.post(`${BASE}/plans/${planId}/addons`, { data: body }) + expect(res.status(), `plan-addon attach failed: ${await res.text()}`).toBe(201) + const planAddon = await res.json() + expect(planAddon.addon.id).toBe(addonId) + expect(planAddon.from_plan_phase).toBe(phaseKey) + planAddonId = planAddon.id + expect(planAddonId).toBeTruthy() + }) + + test('publishes the plan', async ({ request }) => { + expect(planId).toBeTruthy() + const res = await request.post(`${BASE}/plans/${planId}/publish`) + expect(res.status(), `plan publish failed: ${await res.text()}`).toBe(200) + const plan = await res.json() + expect(plan.status).toBe('active') + expect(plan.effective_from).toBeTruthy() + }) +}) diff --git a/e2e/playwright/tests/quickstart/ingest-and-query.spec.ts b/e2e/playwright/tests/quickstart/ingest-and-query.spec.ts new file mode 100644 index 0000000000..65a3f5f3ce --- /dev/null +++ b/e2e/playwright/tests/quickstart/ingest-and-query.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { BASE, createMeter, ingestEvent } from '../../helpers/catalog' + +test.describe.configure({ mode: 'serial' }) + +test.describe('Quickstart > ingest and query', () => { + let meterId: string + let eventType: string + let subject: string + + // Three events whose duration_ms values sum to a known total. Different ids + // and timestamps to mirror the quickstart docs. + const events = [ + { id: faker.string.uuid(), time: '2023-01-01T00:00:00Z', durationMs: 10 }, + { id: faker.string.uuid(), time: '2023-01-01T01:00:00Z', durationMs: 20 }, + { id: faker.string.uuid(), time: '2023-01-02T00:00:00Z', durationMs: 30 }, + ] + const expectedSum = events.reduce((acc, e) => acc + e.durationMs, 0) + + test('creates a meter', async ({ request }) => { + eventType = `request_${faker.string.alphanumeric({ length: 8, casing: 'lower' })}` + const meter = await createMeter(request, { + aggregation: 'sum', + event_type: eventType, + value_property: '$.duration_ms', + }) + meterId = meter.id + expect(meterId).toBeTruthy() + }) + + test('ingests three CloudEvents and queries the meter', async ({ request }) => { + expect(meterId).toBeTruthy() + subject = `customer_${faker.string.alphanumeric({ length: 12, casing: 'lower' })}` + + for (const ev of events) { + await ingestEvent(request, { + id: ev.id, + source: 'playwright-smoke', + type: eventType, + subject, + time: ev.time, + data: { duration_ms: String(ev.durationMs) }, + }) + } + + // Sink processing is async (Kafka -> ClickHouse). Poll the query endpoint + // until the aggregated value reflects all three events. + await expect.poll( + async () => { + const res = await request.post(`${BASE}/meters/${meterId}/query`, { + data: { + from: '2023-01-01T00:00:00Z', + to: '2023-01-03T00:00:00Z', + filters: { dimensions: { subject: { eq: subject } } }, + }, + }) + if (res.status() !== 200) return -1 + const body = await res.json() + if (!Array.isArray(body.data) || body.data.length === 0) return 0 + return Number(body.data[0].value) + }, + { + timeout: 60_000, + intervals: [500, 1000, 2000, 5000], + message: `meter ${meterId} did not converge to ${expectedSum} for subject ${subject}`, + }, + ).toBe(expectedSum) + }) +}) From 38f0f4df2b218eb7be5aa4042664bfef8768c7ca Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Thu, 7 May 2026 17:09:29 +0200 Subject: [PATCH 6/7] fix: catalog.ts to use the v3 api instead of the v1 --- e2e/playwright/helpers/catalog.ts | 116 ++++++++++++++++-------------- 1 file changed, 63 insertions(+), 53 deletions(-) diff --git a/e2e/playwright/helpers/catalog.ts b/e2e/playwright/helpers/catalog.ts index 862228bede..96b7c1aaf7 100644 --- a/e2e/playwright/helpers/catalog.ts +++ b/e2e/playwright/helpers/catalog.ts @@ -1,98 +1,108 @@ -import type { APIRequestContext } from '@playwright/test' -import { expect } from '@playwright/test' -import { faker } from '@faker-js/faker' +import type { APIRequestContext } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { faker } from "@faker-js/faker"; -export const BASE = '/api/v3/openmeter' -export const V1_BASE = '/api/v1' +export const BASE = "/api/v3/openmeter"; export type CreateMeterOverrides = { - key?: string - name?: string - aggregation?: 'sum' | 'count' | 'unique_count' | 'avg' | 'min' | 'max' | 'latest' - event_type?: string - value_property?: string - description?: string -} + key?: string; + name?: string; + aggregation?: + | "sum" + | "count" + | "unique_count" + | "avg" + | "min" + | "max" + | "latest"; + event_type?: string; + value_property?: string; + description?: string; +}; export type Meter = { - id: string - key: string -} + id: string; + key: string; +}; export async function createMeter( request: APIRequestContext, overrides: CreateMeterOverrides = {}, ): Promise { const body = { - key: overrides.key ?? `meter_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, - name: overrides.name ?? 'Test Meter', - aggregation: overrides.aggregation ?? 'sum', - event_type: overrides.event_type ?? 'request', - value_property: overrides.value_property ?? '$.duration_ms', + key: + overrides.key ?? + `meter_${faker.string.alphanumeric({ length: 16, casing: "lower" })}`, + name: overrides.name ?? "Test Meter", + aggregation: overrides.aggregation ?? "sum", + event_type: overrides.event_type ?? "request", + value_property: overrides.value_property ?? "$.duration_ms", ...(overrides.description ? { description: overrides.description } : {}), - } + }; - const res = await request.post(`${BASE}/meters`, { data: body }) - expect(res.status(), `meter create failed: ${await res.text()}`).toBe(201) - const meter = await res.json() - return { id: meter.id, key: meter.key } + const res = await request.post(`${BASE}/meters`, { data: body }); + expect(res.status(), `meter create failed: ${await res.text()}`).toBe(201); + const meter = await res.json(); + return { id: meter.id, key: meter.key }; } export type CreateFeatureOverrides = { - key?: string - name?: string - meterId?: string -} + key?: string; + name?: string; + meterId?: string; +}; export type Feature = { - id: string - key: string -} + id: string; + key: string; +}; export async function createFeature( request: APIRequestContext, overrides: CreateFeatureOverrides = {}, ): Promise { const body: Record = { - key: overrides.key ?? `feature_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, - name: overrides.name ?? 'Test Feature', - } + key: + overrides.key ?? + `feature_${faker.string.alphanumeric({ length: 16, casing: "lower" })}`, + name: overrides.name ?? "Test Feature", + }; if (overrides.meterId) { - body.meter = { id: overrides.meterId } + body.meter = { id: overrides.meterId }; } - const res = await request.post(`${BASE}/features`, { data: body }) - expect(res.status(), `feature create failed: ${await res.text()}`).toBe(201) - const feature = await res.json() - return { id: feature.id, key: feature.key } + const res = await request.post(`${BASE}/features`, { data: body }); + expect(res.status(), `feature create failed: ${await res.text()}`).toBe(201); + const feature = await res.json(); + return { id: feature.id, key: feature.key }; } export type CloudEvent = { - id: string - source: string - type: string - subject: string - time?: string - data?: Record -} + id: string; + source: string; + type: string; + subject: string; + time?: string; + data?: Record; +}; export async function ingestEvent( request: APIRequestContext, event: CloudEvent, ): Promise { const body = { - specversion: '1.0', + specversion: "1.0", id: event.id, source: event.source, type: event.type, subject: event.subject, ...(event.time ? { time: event.time } : {}), ...(event.data ? { data: event.data } : {}), - } + }; - const res = await request.post(`${V1_BASE}/events`, { - headers: { 'Content-Type': 'application/cloudevents+json' }, + const res = await request.post(`${BASE}/events`, { + headers: { "Content-Type": "application/cloudevents+json" }, data: body, - }) - expect(res.status(), `ingest failed: ${await res.text()}`).toBe(204) + }); + expect(res.status(), `ingest failed: ${await res.text()}`).toBe(202); } From 75a9a63a34ac6c4504afcbf11cefaa8a36bdf70c Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Thu, 7 May 2026 17:36:47 +0200 Subject: [PATCH 7/7] chore: add jsdoc to the test files --- .agents/skills/e2e-playwright/SKILL.md | 24 +++++++++++++++++++ .../tests/currencies/create-and-list.spec.ts | 12 ++++++++++ .../plan-addon-lifecycle.spec.ts | 20 ++++++++++++++++ .../tests/quickstart/ingest-and-query.spec.ts | 15 ++++++++++++ 4 files changed, 71 insertions(+) diff --git a/.agents/skills/e2e-playwright/SKILL.md b/.agents/skills/e2e-playwright/SKILL.md index 19bf2897f6..e45b46b494 100644 --- a/.agents/skills/e2e-playwright/SKILL.md +++ b/.agents/skills/e2e-playwright/SKILL.md @@ -139,6 +139,30 @@ tests/subscriptions/subscribe-and-cancel.spec.ts tests/billing/invoice-lifecycle.spec.ts ``` +### File header + +Every spec file starts with a JSDoc-style multiline comment that names the +suite, summarises the journey, and lists the endpoints it exercises. Add +any non-obvious preconditions (dependent worker that must be running, +ordering constraints, eventual-consistency waits): + +```typescript +/** + * + * + * One short paragraph describing the journey or scenarios. + * + * Endpoints exercised: + * POST /api/v3/openmeter/<...> + * GET /api/v3/openmeter/<...> + * + * (Optional) Notes on preconditions or ordering. + */ +``` + +Place the header above the first `import`. Keep it under ~15 lines — +this is orientation, not documentation. + ### Test structure Import directly from `@playwright/test` and use the `request` fixture. Define a `BASE` constant for the API path prefix. Use `faker.string.uuid()` for unique string fields: diff --git a/e2e/playwright/tests/currencies/create-and-list.spec.ts b/e2e/playwright/tests/currencies/create-and-list.spec.ts index cf0fc72ff3..da7cb43900 100644 --- a/e2e/playwright/tests/currencies/create-and-list.spec.ts +++ b/e2e/playwright/tests/currencies/create-and-list.spec.ts @@ -1,3 +1,15 @@ +/** + * Currencies > create and list + * + * Verifies the custom-currency happy path: a created currency is returned + * by the list endpoint, the type filter partitions custom and fiat + * currencies correctly, and create requests with missing required fields + * (name, code) are rejected with a 400 schema error. + * + * Endpoints exercised: + * POST /api/v3/openmeter/currencies/custom + * GET /api/v3/openmeter/currencies (with filter[type]) + */ import { test, expect } from '@playwright/test' import { faker } from '@faker-js/faker' diff --git a/e2e/playwright/tests/productcatalog/plan-addon-lifecycle.spec.ts b/e2e/playwright/tests/productcatalog/plan-addon-lifecycle.spec.ts index 5ea9e19bd4..74a9ac8111 100644 --- a/e2e/playwright/tests/productcatalog/plan-addon-lifecycle.spec.ts +++ b/e2e/playwright/tests/productcatalog/plan-addon-lifecycle.spec.ts @@ -1,3 +1,23 @@ +/** + * Plan + addon > smoke lifecycle + * + * Sequential lifecycle smoke covering the v3 product-catalog plan/addon + * happy path: provision a meter and feature, create a draft plan with a + * flat rate card, attach unit and graduated rate cards, reject a + * duplicate-key rate card, then create + publish an addon, attach it to + * the plan, and publish the plan. + * + * Endpoints exercised: + * POST /api/v3/openmeter/meters + * POST /api/v3/openmeter/features + * POST /api/v3/openmeter/plans (+ PUT, + GET, + /publish) + * POST /api/v3/openmeter/addons (+ /publish) + * POST /api/v3/openmeter/plans/{planId}/addons + * + * Tests run serially (test.describe.configure mode 'serial') and share + * state via outer-scope variables; an early failure intentionally fails + * the rest. + */ import { test, expect } from '@playwright/test' import { faker } from '@faker-js/faker' import { BASE, createMeter, createFeature } from '../../helpers/catalog' diff --git a/e2e/playwright/tests/quickstart/ingest-and-query.spec.ts b/e2e/playwright/tests/quickstart/ingest-and-query.spec.ts index 65a3f5f3ce..4da75ecc5b 100644 --- a/e2e/playwright/tests/quickstart/ingest-and-query.spec.ts +++ b/e2e/playwright/tests/quickstart/ingest-and-query.spec.ts @@ -1,3 +1,18 @@ +/** + * Quickstart > ingest and query + * + * Mirrors the OpenMeter quickstart flow: create a SUM meter, ingest three + * CloudEvents with distinct ids and timestamps, then poll the meter query + * endpoint until the aggregated value reflects all events. + * + * Endpoints exercised: + * POST /api/v3/openmeter/meters + * POST /api/v3/openmeter/events (CloudEvents JSON, 202 accepted) + * POST /api/v3/openmeter/meters/{meterId}/query + * + * Requires the sink-worker to be running alongside the API server; the + * v3 query stays at 0 until events are processed off Kafka into ClickHouse. + */ import { test, expect } from '@playwright/test' import { faker } from '@faker-js/faker' import { BASE, createMeter, ingestEvent } from '../../helpers/catalog'