0f952d6c1b
Four domain-model changes driven by exercising the deployed 2.0 build: - EOL moves from manufacturer to MPN via new PartModel catalog table, so alerts fire on the thing that actually ages. - Repairs re-home to Host (required hostId + problem text) with an optional RepairJobPart join for affected parts; drop Part.replacementPartId. - New /repairs/:id detail page with editable problem, part list, and a RepairComment thread (REPAIR_COMMENTED events fan out to each problem part's timeline). - Host.assetId (required, unique) surfaces prominently on the repair page so techs can confirm they're touching the right box. Single destructive migration reshapes existing dev data. All 7 packages typecheck clean; 30 API tests pass (9 new covering host membership, upsertByMpn idempotency + race, assetId 409, comment userId stamping). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
128 lines
3.9 KiB
TypeScript
128 lines
3.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { BulkPartsRequest, CreatePartRequest, PartListQuery, UpdatePartRequest } from './parts.js';
|
|
|
|
const mfgId = '11111111-1111-4111-8111-111111111111';
|
|
const binId = '22222222-2222-4222-8222-222222222222';
|
|
const modelId = '44444444-4444-4444-8444-444444444444';
|
|
|
|
describe('CreatePartRequest', () => {
|
|
it('accepts a partModelId-based payload', () => {
|
|
const r = CreatePartRequest.parse({
|
|
serialNumber: 'SN-1',
|
|
partModelId: modelId,
|
|
});
|
|
expect(r.serialNumber).toBe('SN-1');
|
|
});
|
|
|
|
it('accepts a manufacturerId + mpn payload (auto-upsert path)', () => {
|
|
const r = CreatePartRequest.parse({
|
|
serialNumber: 'SN-1',
|
|
manufacturerId: mfgId,
|
|
mpn: 'MPN-1',
|
|
});
|
|
expect(r.mpn).toBe('MPN-1');
|
|
});
|
|
|
|
it('rejects when neither partModelId nor (manufacturerId + mpn) is provided', () => {
|
|
expect(
|
|
CreatePartRequest.safeParse({ serialNumber: 'SN-1', manufacturerId: mfgId }).success,
|
|
).toBe(false);
|
|
expect(CreatePartRequest.safeParse({ serialNumber: 'SN-1' }).success).toBe(false);
|
|
});
|
|
|
|
it('rejects empty serial', () => {
|
|
expect(
|
|
CreatePartRequest.safeParse({ serialNumber: '', partModelId: modelId }).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects negative price', () => {
|
|
const res = CreatePartRequest.safeParse({
|
|
serialNumber: 'X',
|
|
partModelId: modelId,
|
|
price: -1,
|
|
});
|
|
expect(res.success).toBe(false);
|
|
});
|
|
|
|
it('caps tagIds at 32', () => {
|
|
const tagIds = Array.from({ length: 33 }, () => '33333333-3333-4333-8333-333333333333');
|
|
expect(
|
|
CreatePartRequest.safeParse({
|
|
serialNumber: 'X',
|
|
partModelId: modelId,
|
|
tagIds,
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('UpdatePartRequest', () => {
|
|
it('requires at least one field', () => {
|
|
expect(UpdatePartRequest.safeParse({}).success).toBe(false);
|
|
});
|
|
|
|
it('accepts a single field', () => {
|
|
expect(UpdatePartRequest.safeParse({ notes: 'hi' }).success).toBe(true);
|
|
});
|
|
|
|
it('permits nullable binId to clear location', () => {
|
|
const r = UpdatePartRequest.parse({ binId: null });
|
|
expect(r.binId).toBeNull();
|
|
});
|
|
|
|
it('permits nullable hostId to clear host assignment', () => {
|
|
const r = UpdatePartRequest.parse({ hostId: null });
|
|
expect(r.hostId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('PartListQuery', () => {
|
|
it('defaults page=1, pageSize=20', () => {
|
|
const r = PartListQuery.parse({});
|
|
expect(r.page).toBe(1);
|
|
expect(r.pageSize).toBe(20);
|
|
});
|
|
|
|
it('coerces string numbers from query strings', () => {
|
|
const r = PartListQuery.parse({ page: '3', pageSize: '50' });
|
|
expect(r.page).toBe(3);
|
|
expect(r.pageSize).toBe(50);
|
|
});
|
|
|
|
it('clamps pageSize to the 100 max', () => {
|
|
expect(PartListQuery.safeParse({ pageSize: '500' }).success).toBe(false);
|
|
});
|
|
|
|
it('parses eolOnly from string and boolean', () => {
|
|
expect(PartListQuery.parse({ eolOnly: 'true' }).eolOnly).toBe(true);
|
|
expect(PartListQuery.parse({ eolOnly: 'false' }).eolOnly).toBe(false);
|
|
expect(PartListQuery.parse({ eolOnly: true }).eolOnly).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('BulkPartsRequest', () => {
|
|
it('requires at least one mutation field', () => {
|
|
expect(BulkPartsRequest.safeParse({ ids: [mfgId] }).success).toBe(false);
|
|
});
|
|
|
|
it('accepts state mutation', () => {
|
|
expect(BulkPartsRequest.safeParse({ ids: [mfgId], state: 'SPARE' }).success).toBe(true);
|
|
});
|
|
|
|
it('accepts binId=null to unassign', () => {
|
|
const r = BulkPartsRequest.parse({ ids: [mfgId], binId: null });
|
|
expect(r.binId).toBeNull();
|
|
});
|
|
|
|
it('accepts hostId=null to unassign', () => {
|
|
const r = BulkPartsRequest.parse({ ids: [mfgId], hostId: null });
|
|
expect(r.hostId).toBeNull();
|
|
});
|
|
|
|
it('caps ids at 500', () => {
|
|
const ids = Array.from({ length: 501 }, () => binId);
|
|
expect(BulkPartsRequest.safeParse({ ids, state: 'SPARE' }).success).toBe(false);
|
|
});
|
|
});
|