proposal-system/web/e2e/smoke.spec.ts
Adam Moussa d6404edefc
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer
Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency
wire contract shipped in 6a.

shared/api-contracts (additive):
- rowVersion: string on ProposalListItem, ProposalDetail, and LineItem
- proposalVersion?: string on UpdateProposalRequest and
  BulkUpdateLineItemsRequest; new ProposalVersionRequest
- ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004
  409 envelope (guarded path; the unguarded fallback carries no state)
- matching zod schemas, all kept under the satisfies z.ZodType<T> coupling

web:
- lib/api/errors.ts: ConflictError carrying the server's reloaded
  currentState; client.ts interceptor throws it on 409 (WEB-M2 401
  handling untouched)
- guarded mutations read the token from the cached proposal detail at
  mutate time; the save flow chains rotated tokens (PUT response token
  into the bulk replace) and ends with a detail refetch so
  approve-after-save never sends a stale version
- 409 recovery in the admin use-cases: write currentState into the
  detail cache, invalidateProposalViews() (stale-queue invariant holds
  on the failure path too), and toast the conflict instead of the
  generic failure message
- e2e smoke mock payloads carry rowVersion; vitest coverage for the
  interceptor ConflictError paths, token threading/rotation, and 409
  cache recovery

Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright
smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00

115 lines
3.8 KiB
TypeScript

// Browser smoke: dev-login → dashboard shell → proposal list (Phase 5, web CI).
// The API is fully mocked via route interception so the smoke runs in pure CI
// with no backend — it exercises the login flow, auth context, protected
// routing, the app shell, and list rendering end-to-end in a real browser.
import { test, expect, type Page } from '@playwright/test';
function fakeJwt(): string {
const b64 = (o: object) =>
Buffer.from(JSON.stringify(o))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `${b64({ alg: 'HS256' })}.${b64({
sub: 'e2e-user',
exp: Math.floor(Date.now() / 1000) + 3600,
})}.e2e-signature`;
}
const authUser = {
id: 'e2e-user',
email: 'adam@seahavenind.com',
displayName: 'Adam Moussa',
role: 'SysAdmin',
token: fakeJwt(),
};
const proposals = [
{
id: 'p-1',
proposalNumber: 'PROP-2026-001',
customerName: 'Harbor Point Marina',
workOrderNumber: 'WO-1001',
serviceCategory: 'HVAC',
priority: 'Standard',
status: 'InReview',
totalBidAmount: 12500,
submittedAt: '2026-07-01T12:00:00Z',
submittedByName: 'Adam Moussa',
assignedAdminName: null,
rowVersion: 'AAAAAAAAAAE=',
},
{
id: 'p-2',
proposalNumber: 'PROP-2026-002',
customerName: 'Seaside Property Group',
workOrderNumber: 'WO-1002',
serviceCategory: 'Electrical',
priority: 'Urgent',
status: 'Approved',
totalBidAmount: 4300,
submittedAt: '2026-07-05T09:30:00Z',
submittedByName: 'Adam Moussa',
assignedAdminName: 'Sarah Chen',
rowVersion: 'AAAAAAAAAAI=',
},
];
async function mockApi(page: Page) {
// Match on pathname, not a '**/api/**' glob: vite dev serves app modules from
// paths like /src/lib/api/client.ts, which such a glob would swallow.
// Registered first = matched last (Playwright checks routes newest-first):
// generic fallback so nothing the shell fetches hangs or hard-errors.
await page.route(
(url) => url.pathname.startsWith('/api/'),
(route) => route.fulfill({ json: {} }),
);
await page.route(
(url) => url.pathname === '/api/auth/dev-login',
(route) => route.fulfill({ json: authUser }),
);
await page.route(
(url) => url.pathname === '/api/proposals/stats',
(route) =>
route.fulfill({ json: { totalCount: 2, inReviewCount: 1, approvedCount: 1, sentCount: 0 } }),
);
await page.route(
(url) => url.pathname === '/api/proposals' && url.searchParams.has('page'),
(route) =>
route.fulfill({
json: { items: proposals, totalCount: proposals.length, page: 1, pageSize: 12 },
}),
);
}
test('dev-login lands on the dashboard and the proposal list renders', async ({ page }) => {
await mockApi(page);
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByText('Welcome back')).toBeVisible();
await page.getByRole('button', { name: /SysAdmin/ }).click();
// Authenticated shell: topbar user chip + dashboard content.
await expect(page).toHaveURL('/');
await expect(page.getByText('Adam Moussa').first()).toBeVisible();
await expect(page.getByText('Recent Proposals')).toBeVisible();
// Proposal list via sidebar nav.
await page.getByText('My Proposals', { exact: true }).click();
await expect(page).toHaveURL(/\/proposals$/);
await expect(page.getByRole('heading', { name: 'My Proposals' })).toBeVisible();
await expect(page.getByText('Harbor Point Marina')).toBeVisible();
await expect(page.getByText('Seaside Property Group')).toBeVisible();
});
test('unauthenticated visits are bounced to the login card', async ({ page }) => {
await mockApi(page);
await page.goto('/proposals');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByText('Welcome back')).toBeVisible();
});