diff --git a/.agents/skills/e2e-playwright/SKILL.md b/.agents/skills/e2e-playwright/SKILL.md new file mode 100644 index 0000000000..e45b46b494 --- /dev/null +++ b/.agents/skills/e2e-playwright/SKILL.md @@ -0,0 +1,360 @@ +--- +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. 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 +--- + +# 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 +``` + +### 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: + +```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/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9d3836a9fd..493476ca17 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -819,3 +819,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: ${{ !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 < { + 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(`${BASE}/events`, { + headers: { "Content-Type": "application/cloudevents+json" }, + data: body, + }); + expect(res.status(), `ingest failed: ${await res.text()}`).toBe(202); +} 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..da7cb43900 --- /dev/null +++ b/e2e/playwright/tests/currencies/create-and-list.spec.ts @@ -0,0 +1,95 @@ +/** + * 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' + +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() + + 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.created_at).toBeTruthy() + + 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 createRes = await request.post(`${BASE}/currencies/custom`, { + // 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() + + const listRes = await request.get(`${BASE}/currencies`, { + params: { 'page[size]': '1000', 'filter[type]': 'custom' }, + }) + expect(listRes.status()).toBe(200) + const { data } = await listRes.json() + for (const item of data) { + expect(item.type).toBe('custom') + } + 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') + } + }) + + 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') + }) + } +}) 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..74a9ac8111 --- /dev/null +++ b/e2e/playwright/tests/productcatalog/plan-addon-lifecycle.spec.ts @@ -0,0 +1,244 @@ +/** + * 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' + +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..4da75ecc5b --- /dev/null +++ b/e2e/playwright/tests/quickstart/ingest-and-query.spec.ts @@ -0,0 +1,85 @@ +/** + * 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' + +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) + }) +})