From faec7585b3f9522b4366b0861e6a2efc9e70595c Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Sat, 28 Mar 2026 12:29:04 -0400 Subject: [PATCH] feat(automation): Initial Automation Tests --- .github/workflows/ci.yml | 28 +++++ tests/e2e/.env.example | 8 ++ tests/e2e/README.md | 47 ++++++++ tests/e2e/fixtures.seed.example.json | 15 +++ tests/e2e/package-lock.json | 96 ++++++++++++++++ tests/e2e/package.json | 15 +++ tests/e2e/playwright.config.ts | 17 +++ tests/e2e/specs/api-smoke.spec.ts | 25 +++++ tests/e2e/specs/business-flow.spec.ts | 151 ++++++++++++++++++++++++++ tests/e2e/support/api-client.ts | 47 ++++++++ tests/e2e/support/config.ts | 29 +++++ tests/e2e/tsconfig.json | 12 ++ 12 files changed, 490 insertions(+) create mode 100644 tests/e2e/.env.example create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/fixtures.seed.example.json create mode 100644 tests/e2e/package-lock.json create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.ts create mode 100644 tests/e2e/specs/api-smoke.spec.ts create mode 100644 tests/e2e/specs/business-flow.spec.ts create mode 100644 tests/e2e/support/api-client.ts create mode 100644 tests/e2e/support/config.ts create mode 100644 tests/e2e/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62d4322..e838b3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,3 +80,31 @@ jobs: - name: Test run: dotnet test ./JobFlow.API/JobFlow.API.csproj -c Release --no-build + + api-e2e-playwright: + name: API E2E (Playwright) + runs-on: ubuntu-latest + if: ${{ secrets.JOBFLOW_API_BASE_URL != '' && secrets.JOBFLOW_API_BEARER_TOKEN != '' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: tests/e2e/package-lock.json + + - name: Install E2E dependencies + working-directory: ./tests/e2e + run: npm ci + + - name: Run API Playwright tests + working-directory: ./tests/e2e + env: + API_BASE_URL: ${{ secrets.JOBFLOW_API_BASE_URL }} + JOBFLOW_API_BEARER_TOKEN: ${{ secrets.JOBFLOW_API_BEARER_TOKEN }} + JOBFLOW_ORGANIZATION_ID: ${{ secrets.JOBFLOW_ORGANIZATION_ID }} + run: npm run test:e2e diff --git a/tests/e2e/.env.example b/tests/e2e/.env.example new file mode 100644 index 0000000..0b362ff --- /dev/null +++ b/tests/e2e/.env.example @@ -0,0 +1,8 @@ +# API endpoint for Playwright API E2E tests +API_BASE_URL=https://localhost:5099 + +# Seeded account JWT containing organizationId claim (required for business-flow tests) +JOBFLOW_API_BEARER_TOKEN= + +# Optional explicit org id used for assertions and payload defaults +JOBFLOW_ORGANIZATION_ID= diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..a523d5c --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,47 @@ +# JobFlow API E2E (Playwright) + +This folder provides black-box API automation using Playwright's request client. + +## Why Playwright for API + +- Reuses one runner across UI and API automation. +- Fast HTTP smoke tests and contract checks. +- Good reporting and CI ergonomics. + +## Setup + +1. Start JobFlow API locally. +2. `cd tests/e2e` +3. `npm install` +4. Configure env (see `.env.example`): + - PowerShell: `$env:API_BASE_URL = "https://localhost:5099"` + - PowerShell: `$env:JOBFLOW_API_BEARER_TOKEN = ""` + - PowerShell: `$env:JOBFLOW_ORGANIZATION_ID = ""` +5. `npm run test:e2e` + +## Current coverage + +- Swagger UI availability. +- OpenAPI document availability. +- Unknown route behavior. +- End-to-end business lifecycle: + - Create client + - Create estimate + - Upsert job + - Create and fetch invoice + +## Seeded fixtures + +- `fixtures.seed.example.json` documents required seed assumptions. +- `JOBFLOW_API_BEARER_TOKEN` should be a token whose claims include `organizationId`. +- Business-flow tests auto-skip when token is not set. + +## Next workflow scenarios to automate + +1. Firebase login handshake (`/api/auth/login-with-firebase`) with a seeded test identity. +2. Onboarding checklist fetch for a test organization. +3. Client create/list/update lifecycle. +4. Estimate create -> revise -> accept path. +5. Job create/schedule/assignment path. +6. Invoice issue/payment record path. +7. Subscription-gated endpoint behavior by plan. diff --git a/tests/e2e/fixtures.seed.example.json b/tests/e2e/fixtures.seed.example.json new file mode 100644 index 0000000..cec89c4 --- /dev/null +++ b/tests/e2e/fixtures.seed.example.json @@ -0,0 +1,15 @@ +{ + "seededIdentity": { + "description": "User whose JWT includes organizationId claim", + "source": "Firebase login -> API login-with-firebase", + "requiredEnv": [ + "JOBFLOW_API_BEARER_TOKEN", + "JOBFLOW_ORGANIZATION_ID" + ] + }, + "sampleClient": { + "firstName": "E2E", + "lastName": "Client", + "emailDomain": "example.test" + } +} diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..1317cb4 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "jobflow-api-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jobflow-api-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.56.1", + "@types/node": "^24.7.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..2385f66 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "jobflow-api-e2e", + "private": true, + "version": "1.0.0", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.56.1", + "@types/node": "^24.7.2" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..3c66199 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; + +const baseURL = process.env.API_BASE_URL ?? 'https://localhost:7090'; + +export default defineConfig({ + testDir: './specs', + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: [['html', { open: 'never' }], ['list']], + use: { + baseURL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + Accept: 'application/json' + } + } +}); diff --git a/tests/e2e/specs/api-smoke.spec.ts b/tests/e2e/specs/api-smoke.spec.ts new file mode 100644 index 0000000..38aee57 --- /dev/null +++ b/tests/e2e/specs/api-smoke.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '@playwright/test'; + +test.describe('JobFlow API smoke', () => { + test('swagger ui is reachable', async ({ request }) => { + const response = await request.get('/swagger/index.html'); + expect(response.ok()).toBeTruthy(); + + const html = await response.text(); + expect(html.toLowerCase()).toContain('swagger'); + }); + + test('openapi document is served', async ({ request }) => { + const response = await request.get('/swagger/v1/swagger.json'); + expect(response.ok()).toBeTruthy(); + + const body = await response.json(); + expect(body).toHaveProperty('paths'); + expect(Object.keys(body.paths).length).toBeGreaterThan(0); + }); + + test('unknown route returns non-success status', async ({ request }) => { + const response = await request.get('/api/this-route-should-not-exist'); + expect(response.status()).toBeGreaterThanOrEqual(400); + }); +}); diff --git a/tests/e2e/specs/business-flow.spec.ts b/tests/e2e/specs/business-flow.spec.ts new file mode 100644 index 0000000..b45d017 --- /dev/null +++ b/tests/e2e/specs/business-flow.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from '@playwright/test'; +import { authJson, unwrapResult } from '../support/api-client'; +import { hasBearerToken, requireBearerToken } from '../support/config'; + +type OrganizationClientDto = { + id: string; + organizationId?: string; + firstName?: string; + lastName?: string; + emailAddress?: string; +}; + +type EstimateDto = { + id: string; + organizationClientId: string; + title?: string; +}; + +type JobDto = { + id: string; + title?: string; + organizationClientId: string; + estimateId?: string; +}; + +type InvoiceDto = { + id: string; + jobId: string; + organizationClientId: string; + invoiceNumber: string; +}; + +test.describe('Business flow API E2E', () => { + test.skip(!hasBearerToken(), 'Requires JOBFLOW_API_BEARER_TOKEN for authenticated flow.'); + + test('create client -> create estimate -> upsert job -> create invoice', async ({ request }) => { + const bearerToken = requireBearerToken(); + const stamp = Date.now(); + const clientEmail = `e2e-client-${stamp}@example.test`; + + const clientPayload = { + firstName: 'E2E', + lastName: `Client-${stamp}`, + emailAddress: clientEmail, + phoneNumber: '555-0100', + city: 'Austin', + state: 'TX', + zipCode: '73301' + }; + + const upsertClientRaw = await authJson( + request, + 'post', + '/api/organization/clients/upsert', + bearerToken, + clientPayload + ); + const createdClient = unwrapResult(upsertClientRaw); + + expect(createdClient.id).toBeTruthy(); + expect((createdClient.emailAddress ?? '').toLowerCase()).toBe(clientEmail.toLowerCase()); + + const estimatePayload = { + organizationClientId: createdClient.id, + title: `E2E Estimate ${stamp}`, + description: 'Automation estimate', + notes: 'Generated by Playwright business-flow suite', + lineItems: [ + { + name: 'Diagnostic Visit', + description: 'Initial site visit', + quantity: 1, + unitPrice: 95.0 + }, + { + name: 'Labor', + description: 'Repair work', + quantity: 2, + unitPrice: 120.0 + } + ] + }; + + const createdEstimate = await authJson( + request, + 'post', + '/api/estimates', + bearerToken, + estimatePayload + ); + + expect(createdEstimate.id).toBeTruthy(); + expect(createdEstimate.organizationClientId).toBe(createdClient.id); + + const jobPayload = { + title: `E2E Job ${stamp}`, + comments: 'Created from e2e estimate', + lifecycleStatus: 0, + organizationClientId: createdClient.id, + estimateId: createdEstimate.id + }; + + const upsertedJob = await authJson( + request, + 'post', + '/api/job/upsert', + bearerToken, + jobPayload + ); + + expect(upsertedJob.id).toBeTruthy(); + expect(upsertedJob.organizationClientId).toBe(createdClient.id); + + const invoicePayload = { + organizationId: process.env.JOBFLOW_ORGANIZATION_ID ?? '00000000-0000-0000-0000-000000000000', + jobId: upsertedJob.id, + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + lineItems: [ + { + description: 'Labor & Materials', + quantity: 1, + unitPrice: 335, + lineTotal: 335 + } + ] + }; + + const createdInvoice = await authJson( + request, + 'post', + '/api/invoice/organization', + bearerToken, + invoicePayload + ); + + expect(createdInvoice.id).toBeTruthy(); + expect(createdInvoice.jobId).toBe(upsertedJob.id); + expect(createdInvoice.organizationClientId).toBe(createdClient.id); + expect(createdInvoice.invoiceNumber).toBeTruthy(); + + const fetchedInvoice = await authJson( + request, + 'get', + `/api/invoice/${createdInvoice.id}`, + bearerToken + ); + + expect(fetchedInvoice.id).toBe(createdInvoice.id); + expect(fetchedInvoice.jobId).toBe(upsertedJob.id); + }); +}); diff --git a/tests/e2e/support/api-client.ts b/tests/e2e/support/api-client.ts new file mode 100644 index 0000000..0234fe2 --- /dev/null +++ b/tests/e2e/support/api-client.ts @@ -0,0 +1,47 @@ +import { APIRequestContext } from '@playwright/test'; + +type HttpMethod = 'get' | 'post' | 'put' | 'delete'; + +export async function authJson( + request: APIRequestContext, + method: HttpMethod, + url: string, + bearerToken: string, + data?: unknown +): Promise { + const response = await request.fetch(url, { + method, + headers: { + Authorization: `Bearer ${bearerToken}`, + 'Content-Type': 'application/json' + }, + data + }); + + const text = await response.text(); + let body: unknown = {}; + + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + + if (!response.ok()) { + throw new Error( + `Request failed: ${method.toUpperCase()} ${url} -> ${response.status()} ${response.statusText()} | ${JSON.stringify(body)}` + ); + } + + return body as T; +} + +export function unwrapResult(payload: unknown): T { + if (payload && typeof payload === 'object' && 'value' in payload) { + return (payload as { value: T }).value; + } + + return payload as T; +} diff --git a/tests/e2e/support/config.ts b/tests/e2e/support/config.ts new file mode 100644 index 0000000..9c7999b --- /dev/null +++ b/tests/e2e/support/config.ts @@ -0,0 +1,29 @@ +export type E2EConfig = { + apiBaseUrl: string; + bearerToken?: string; + organizationId?: string; +}; + +export function readConfig(): E2EConfig { + return { + apiBaseUrl: process.env.API_BASE_URL ?? 'https://localhost:7090', + bearerToken: process.env.JOBFLOW_API_BEARER_TOKEN, + organizationId: process.env.JOBFLOW_ORGANIZATION_ID + }; +} + +export function requireBearerToken(): string { + const token = process.env.JOBFLOW_API_BEARER_TOKEN; + if (!token) { + throw new Error( + 'JOBFLOW_API_BEARER_TOKEN is required for authenticated business-flow tests. ' + + 'Set it from a seeded account login token in local env or GitHub Actions secrets.' + ); + } + + return token; +} + +export function hasBearerToken(): boolean { + return Boolean(process.env.JOBFLOW_API_BEARER_TOKEN); +} diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..be88524 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "types": ["node", "@playwright/test"], + "skipLibCheck": true + }, + "include": ["specs/**/*.ts", "support/**/*.ts", "playwright.config.ts"] +}