proposal-system/web/src/domain/__tests__/schemas.test.ts
Adam Moussa 4629f7a21a
ci(web): Phase 5 — Prettier check + Playwright smoke, org frontend workflow (#225)
* feat(web): adopt SHOC design system and shell layout (ADR 0003)

Port shoc-frontend-new dev's design system with its CSS-variable
single-token-source mechanism:

- src/styles/theme.css: SHOC token file ported verbatim (Montserrat/
  DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/
  shadow/sidebar/header token layers); fonts self-hosted via @fontsource
- src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme
  adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows
  tuple, component overrides; MUI v9 slot renames expressed as class
  selectors); theme.ts is now a re-export
- Shell: SHOC composition (sidebar column + sticky gradient topbar +
  scrolling main); sidebar 244px/76px collapse with brand header row,
  grouped nav, SHOC active treatment (white card + 3px accent bar);
  topbar 100-degree gradient, surface hamburger, gradient avatar pill
- Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as
  sx; wordmark subtitle localized to PROPOSAL SYSTEM)
- Login: SHOC auth-card treatment (centered 384px card on #f9fafb)
- Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed;
  remaining hardcoded hexes replaced with tokens; lucide-react for
  shell/nav icons per SHOC convention

Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots
pixel-sampled against the extracted SHOC spec (all hard values exact,
no blocking deviations).

* feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes

Closes WEB-M5 (web hand-duplicated wire types, standing drift risk):

- shared/api-contracts: rewritten as the authoritative superset of the
  .NET DTOs (ProposalListItem/ProposalDetail with poNumber and
  submittedByName, line item requests, customers, pricing library,
  dashboard, audit, sites, auth, presigned upload, ApiProblem); stale
  Proposal/UpdateLineItemsRequest shapes removed
- shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to
  every wire type via `satisfies z.ZodType<T>` (schema/type drift is now
  a compile error); separate entrypoint so type-only consumers (mobile)
  never pull zod
- web: imports @proposal-system/api-contracts (file: dep + tsconfig
  paths + vite preserveSymlinks); all 7 lib/api modules re-export shared
  types so page imports stay stable; enum unions tightened
  (PricingLibraryPage form state now ServiceCategory-typed)
- fix(web): customer create/update sent a singular `address` field the
  API silently dropped (contract is addresses: string[], CustomerDtos.cs)
  - addresses now round-trip, extra addresses preserved on edit
- api: ProblemDetails responses carry a machine-readable top-level
  `code` (SHOC error-code vocabulary): ValidationFailed,
  InvalidStateTransition, NotFound, Unauthorized, InternalError; new
  BusinessRuleException(code, message) maps to 422 with its code;
  GlobalExceptionHandlerTests cover the full mapping (wire contract)

Cross-checked .NET DTOs vs TS types vs zod schemas with the
orchestrator scanner (Gemini): core domains consistent; internal-only
DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos
admin surface) intentionally uncovered.

Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc,
shared tsc all green.

* feat(web): domain-layer conventions doc + react-hook-form deps (Phase 4 prep)

* feat(web): scaffold domain module layer (proposals, lineItems, customers, pricingLibrary, admin, sites)

Additive-only: pages still use lib/api/* and constants/queryKeys.ts until
the page-migration agents run. Each domain ships api.ts (HTTP moved from
lib/api), types.ts (contract re-exports + view types), schemas.ts (contract
schema re-exports + form schemas with toRequest mappers), and use-cases.ts
(TanStack Query v5 hooks + hierarchical query keys, mirroring current page
invalidations and toast-on-error behavior).

Adds an explicit vite/vitest alias for the
@proposal-system/api-contracts/schemas subpath (package has no exports map)
plus a schema/mapper smoke test suite.

* refactor(web): proposal pages on domain layer, proposal form on react-hook-form

* refactor(web): admin workspace on domain layer

* refactor(web): customer management on domain layer + react-hook-form

* refactor(web): pricing library on domain layer + react-hook-form

* refactor(web): dashboards on domain layer

* test(web): domain use-case hook coverage

* refactor(web): finish domain-layer integration — migrate straggler components, delete legacy api modules

- SimilarProposalsPanel -> useSimilarProposals (domain/admin); inline
  SimilarProposal type replaced by domain/admin/types (identical shape);
  query key joins the admin hierarchical key space
- LineItemEditor type imports -> domain/lineItems/types
- Delete now-orphaned lib/api/{proposals,lineItems,customers,
  pricingLibrary,admin,sites}.ts, constants/queryKeys.ts,
  hooks/usePaginatedList.ts (lib/api/client.ts + auth.ts stay per
  domain README rule 5)

Verify: tsc clean, vitest 52/52, build OK, Playwright smoke of the
authed shell renders on domain hooks.

* fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup)

Correctness:
- State-transition mutations now invalidate every cached view via
  invalidateProposalViews (detail + line items + lists + stats + admin
  dashboard) — approving no longer leaves a stale queue for the
  5-minute staleTime
- Presigned S3 PUT moved to proposals/api.ts with res.ok check — a
  rejected upload is no longer confirmed as uploaded
- toCustomerRequest always sends contactEmail ('' clears); API create
  path normalizes empty->null to match the update path — customer
  emails can now be cleared from the UI
- Shared Number-based numeric form fields (domain/shared/formFields):
  '12abc' no longer silently coerces to 12 in the pricing library
- Customer create/update invalidate customersKeys.all so cached search
  autocompletes see new customers
- AdminWorkspace clears dirty right after a successful implicit save,
  before approve — no false unsaved-changes prompt when approve fails
- ProposalFormPage submit gate and missing-fields caption derive from
  ONE checks list (missing customer is now listed)
- Empty states gated on !err in ProposalListPage/AdminDashboard — no
  contradictory error + 'no proposals' UI
- VendorDataPanel migrated to useVendorProposals (kills the divergent
  ['vendorProposals', id] cache key and the inline apiClient query)
- useCustomerList/usePricingLibraryList get keepPreviousData — no
  TablePagination out-of-range flash on page change

Cleanup:
- Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem,
  useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema,
  toUpdateLineItemEntry); tests moved to the live save path
  (useSaveProposalWorkspace)
- Shared useDebouncedValue hook replaces 4 drifted inline debounce
  copies (one leaked its timer on unmount, two hardcoded 300ms);
  DEBOUNCE_AUTOCOMPLETE=300 named
- Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated
  onError handlers (CLAUDE.md traceability)
- shared/api-contracts gains an exports map; /schemas resolver alias
  deduplicated from 3 copies to the tsconfig paths mapping

Verify: tsc clean, vitest 51/51 (tests updated to pin the new
invalidation/mapper behavior + new '12abc' rejection test),
vite build OK, dotnet 166/166.

* refactor(web): fold Redux auth/ui slices into SHOC-shape auth context + storage module

Phase 4 tail of the SHOC-alignment plan. Matches SHOC's auth shape
(lib/auth storage module + providers/ context split) while keeping the
deliberate divergences:

- sessionStorage, not localStorage (WEB-C1 stands; SHOC's localStorage
  is on the SHOULD-NOT-ALIGN list)
- token acquisition stays in the auth pages (Cognito code exchange /
  dev-login) — the provider only owns session state
- 401 interceptor clears storage directly (WEB-M2 behavior preserved;
  full-page redirect resets provider state)

Sidebar open state moves to plain layout state in App passed down as
props (SHOC (protected)/_layout.tsx pattern), keeping localStorage
persistence. Drops @reduxjs/toolkit and react-redux.

Tests: authSlice tests replaced by authStorage + AuthProvider suites
(QA-C5 coverage preserved); client interceptor tests updated for the
storage-based 401 path. 59 vitest green, tsc clean, vite build OK.
Verified end-to-end headless: login redirect, seeded-session shell,
sidebar toggle persistence, logout, expired/malformed token handling,
RoleGuard bounce; recipe persisted as web/.claude/skills/verify.

* fix(web): harden auth session teardown per /sh-security-review findings

- AUTH-L1 (confirmed medium): logout() now clears the react-query cache —
  the singleton cache survived SPA logout, serving the previous
  principal's cached GETs to the next login in the same tab for up to
  staleTime with no server round-trip.
- AUTH-L3 (confirmed low): isTokenValid decodes base64url before atob —
  valid Cognito JWTs containing '-'/'_' in the payload segment were
  misclassified as expired (login lockout/loop; inherited from the old
  authSlice).
- AUTH-L2 (unverified, hardened anyway): 401 interceptor broadcasts
  AUTH_SESSION_CLEARED_EVENT so AuthProvider drops in-memory state
  synchronously, restoring the old Redux atomic-clear semantics.
- INJ-1 (unverified, hardened anyway): Authorization header only set
  when the stored token is a string.

Each fix pinned by a test; 63 vitest green, tsc clean.

* docs: web stack row reflects auth-context refactor (Redux removed, MUI v9)

* ci(web): Phase 5 — prettier check + Playwright smoke via org frontend workflow

Converts the web CI job from ci-typescript-cdk.yaml (typecheck only) to
ci-typescript-frontend.yaml: format:check, build (tsc -b included),
vitest, and a Playwright chromium smoke. Folds the standalone Web Tests
job into it (aggregator needs updated). Pure CI — no AWS secrets.

The smoke (e2e/smoke.spec.ts) drives dev-login → dashboard shell →
proposal list, plus the unauthenticated bounce, against a fully mocked
API (pathname-anchored route interception — a '**/api/**' glob would
swallow vite's /src/lib/api/* module URLs). Config mirrors SHOC's
playwright.config.ts (port 4173, chromium, dev-server webServer).

Prettier: singleQuote + printWidth 100 to match the existing codebase
style; lint intentionally not added (no ESLint config yet — run-lint
false, out of Phase 5 scope). rollback = revert this workflow file.

* style(web): prettier format pass (mechanical)

npx prettier --write . with the new .prettierrc (singleQuote,
printWidth 100). No functional changes — enforced by format:check in
CI from this PR on.
2026-07-14 01:02:20 +00:00

143 lines
4.7 KiB
TypeScript

// Domain-layer form schemas: derived from the shared contract schemas, with
// string-typed numeric inputs and toXxxRequest() mappers to the wire types.
// Also serves as the resolution smoke test for the
// '@proposal-system/api-contracts/schemas' subpath entrypoint.
import { describe, it, expect } from 'vitest';
import {
createProposalFormSchema,
toCreateProposalRequest,
MIN_SCOPE_LENGTH,
} from '../proposals/schemas';
import { customerFormSchema, toCustomerRequest } from '../customers/schemas';
import {
pricingLibraryItemFormSchema,
toPricingLibraryItemRequest,
} from '../pricingLibrary/schemas';
describe('createProposalFormSchema', () => {
const valid = {
workOrderNumber: 'WO-1001',
poNumber: '',
customerName: 'Acme Corp',
customerAddress: '1 Main St, Islip, NY 11751',
scopeOfWork: 'Replace rooftop HVAC unit and rebalance airflow',
serviceCategory: 'HVAC' as const,
priority: 'Standard' as const,
notes: '',
customCategory: '',
};
it('accepts a complete form', () => {
expect(createProposalFormSchema.safeParse(valid).success).toBe(true);
});
it('rejects scope of work shorter than the minimum', () => {
const result = createProposalFormSchema.safeParse({
...valid,
scopeOfWork: 'x'.repeat(MIN_SCOPE_LENGTH - 1),
});
expect(result.success).toBe(false);
});
it('requires customCategory when serviceCategory is Other', () => {
expect(createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other' }).success).toBe(
false,
);
expect(
createProposalFormSchema.safeParse({
...valid,
serviceCategory: 'Other',
customCategory: 'Roofing',
}).success,
).toBe(true);
});
it('maps to the wire request, prefixing notes with the custom category', () => {
const request = toCreateProposalRequest({
...valid,
serviceCategory: 'Other',
customCategory: 'Roofing',
notes: 'existing note',
});
expect(request.notes).toBe('[Category: Roofing]\nexisting note');
expect(request.poNumber).toBeUndefined();
expect(toCreateProposalRequest(valid).notes).toBeUndefined();
});
});
describe('customerFormSchema', () => {
const valid = { name: 'Acme Corp', address: '1 Main St', contactEmail: '' };
it('accepts a form with empty email', () => {
expect(customerFormSchema.safeParse(valid).success).toBe(true);
});
it('rejects a malformed email but accepts a valid one', () => {
expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'nope' }).success).toBe(false);
expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'a@b.com' }).success).toBe(true);
});
it('maps to addresses[] preserving extra existing addresses', () => {
const request = toCustomerRequest({ name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, [
'1 Old St',
'3 Extra Ave',
]);
// contactEmail is ALWAYS sent — empty string clears the stored email
// (code-review finding, Phase 4: omission made emails impossible to clear).
expect(request).toEqual({
name: 'Acme',
addresses: ['2 New St', '3 Extra Ave'],
contactEmail: '',
});
});
});
describe('pricingLibraryItemFormSchema', () => {
const valid = {
title: 'Condenser coil cleaning',
serviceCategory: 'HVAC' as const,
unit: '',
unitPrice: '',
keywords: '',
description: '',
};
it('accepts empty unitPrice and rejects negatives/non-numbers', () => {
expect(pricingLibraryItemFormSchema.safeParse(valid).success).toBe(true);
expect(pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: '-4' }).success).toBe(
false,
);
expect(pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: 'abc' }).success).toBe(
false,
);
});
it('maps empty unitPrice to null and drops empty optional strings', () => {
const request = toPricingLibraryItemRequest(valid);
expect(request).toEqual({
title: 'Condenser coil cleaning',
serviceCategory: 'HVAC',
unitPrice: null,
});
const priced = toPricingLibraryItemRequest({ ...valid, unitPrice: '12.50', unit: 'each' });
expect(priced.unitPrice).toBe(12.5);
expect(priced.unit).toBe('each');
});
});
describe('shared numeric form fields', () => {
it('rejects trailing garbage that parseFloat would silently coerce', () => {
// Code-review finding (Phase 4): '12abc' passed the old parseFloat-based
// pricing refine as 12; the shared Number-based field must reject it.
expect(
pricingLibraryItemFormSchema.safeParse({
title: 'Coil cleaning',
serviceCategory: 'HVAC',
unit: '',
unitPrice: '12abc',
keywords: '',
description: '',
}).success,
).toBe(false);
});
});