2026-07-13 20:14:41 -04:00
|
|
|
// 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,
|
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
|
|
|
rowVersion: 'AAAAAAAAAAE=',
|
2026-07-13 20:14:41 -04:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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',
|
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
|
|
|
rowVersion: 'AAAAAAAAAAI=',
|
2026-07-13 20:14:41 -04:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|