mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 11:38:56 +00:00
158 lines
5.3 KiB
TypeScript
158 lines
5.3 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';
|
||
|
|
import { lineItemRowFormSchema, toUpdateLineItemEntry } from '../lineItems/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']
|
||
|
|
);
|
||
|
|
expect(request).toEqual({ name: 'Acme', addresses: ['2 New St', '3 Extra Ave'] });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
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('lineItemRowFormSchema', () => {
|
||
|
|
const valid = {
|
||
|
|
description: 'Install thermostat',
|
||
|
|
unit: 'each',
|
||
|
|
pricingMode: 'UnitPrice' as const,
|
||
|
|
quantity: '2',
|
||
|
|
unitPrice: '150',
|
||
|
|
totalPrice: '300',
|
||
|
|
};
|
||
|
|
|
||
|
|
it('accepts numeric strings and an empty unitPrice', () => {
|
||
|
|
expect(lineItemRowFormSchema.safeParse(valid).success).toBe(true);
|
||
|
|
expect(lineItemRowFormSchema.safeParse({ ...valid, unitPrice: '' }).success).toBe(true);
|
||
|
|
expect(lineItemRowFormSchema.safeParse({ ...valid, quantity: '' }).success).toBe(false);
|
||
|
|
expect(lineItemRowFormSchema.safeParse({ ...valid, totalPrice: '-1' }).success).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('maps to the wire entry with editor-managed identity', () => {
|
||
|
|
const entry = toUpdateLineItemEntry(valid, { id: null, sortOrder: 3, source: 'Manual' });
|
||
|
|
expect(entry).toEqual({
|
||
|
|
id: null,
|
||
|
|
description: 'Install thermostat',
|
||
|
|
quantity: 2,
|
||
|
|
unit: 'each',
|
||
|
|
unitPrice: 150,
|
||
|
|
totalPrice: 300,
|
||
|
|
pricingMode: 'UnitPrice',
|
||
|
|
sortOrder: 3,
|
||
|
|
source: 'Manual',
|
||
|
|
});
|
||
|
|
const noUnitPrice = toUpdateLineItemEntry(
|
||
|
|
{ ...valid, unitPrice: '' },
|
||
|
|
{ id: 'li-1', sortOrder: 0, source: 'AI' }
|
||
|
|
);
|
||
|
|
expect(noUnitPrice.unitPrice).toBeNull();
|
||
|
|
});
|
||
|
|
});
|