proposal-system/web/src/domain/__tests__/schemas.test.ts
Adam Moussa 1f55a59445
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.
2026-07-13 19:06:08 -04:00

137 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);
});
});