diff --git a/server/lib/validations/inspection.schema.ts b/server/lib/validations/inspection.schema.ts index 0c2d53e2..0dca7211 100644 --- a/server/lib/validations/inspection.schema.ts +++ b/server/lib/validations/inspection.schema.ts @@ -377,10 +377,11 @@ export const InspectionHubResponseSchema = createApiResponseSchema(InspectionHub * template, email defaults to the inspection's clientEmail. */ export const SendAgreementRequestSchema = z.object({ - // Plain non-empty string, NOT .uuid(): agreements.id is TEXT and the handler - // already 422s when the id doesn't resolve inside the tenant — a format gate - // would only reject legitimate non-UUID rows (e.g. imported/seeded data). - agreementId: z.string().min(1).optional().describe('Agreement template id; defaults to the tenant first agreement'), + // Canonical UUID: agreements.id is always crypto.randomUUID() in production + // (the Spectora import preserves external ids only for template-internal items, + // never as the agreements PK). Pre-launch we enforce the canonical format rather + // than tolerate non-UUID ids — only test seeds were ever non-UUID. + agreementId: z.string().uuid().optional().describe('Agreement template id; defaults to the tenant first agreement'), email: z.string().email().optional().describe('Recipient email; defaults to inspection.clientEmail'), }).openapi('SendAgreementRequest'); diff --git a/tests/unit/inspection-agreement-request.spec.ts b/tests/unit/inspection-agreement-request.spec.ts index dca91b74..4bdc80db 100644 --- a/tests/unit/inspection-agreement-request.spec.ts +++ b/tests/unit/inspection-agreement-request.spec.ts @@ -126,19 +126,14 @@ describe('POST /api/inspections/:id/agreement-requests (Task 7, #111)', () => { expect(sendAgreementRequest.mock.calls[0][2]).toBe('Standard Agreement'); }); - it('accepts a non-UUID agreementId (agreements.id is TEXT, not a UUID column)', async () => { - // Regression: the body schema once gated agreementId with .uuid(), which - // 422'd legitimate non-UUID rows (seeded/imported templates). agreements.id - // is plain TEXT; tenant ownership — not id format — is what gates the send. - const TEXT_ID = 'agr-seeded-not-a-uuid'; - await seedAgreement(TEXT_ID, 'Seeded Agreement'); - const res = await post({ agreementId: TEXT_ID }); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: { id: string } }; - const row = await db.select().from(schema.agreementRequests) - .where(eq(schema.agreementRequests.id, body.data.id)).get(); - expect(row?.agreementId).toBe(TEXT_ID); - expect(sendAgreementRequest).toHaveBeenCalledTimes(1); + it('rejects a non-UUID agreementId (canonical UUID enforced; production ids are always randomUUID)', async () => { + // agreements.id is always crypto.randomUUID() in production (the Spectora + // import preserves external ids only for template-internal items, never as + // the PK). Pre-launch we enforce the canonical format rather than tolerate + // non-UUID ids — the schema's .uuid() gate rejects malformed ids up front. + const res = await post({ agreementId: 'agr-not-a-uuid' }); + expect(res.status).toBe(400); + expect(sendAgreementRequest).not.toHaveBeenCalled(); }); it('explicit body overrides win over the defaults', async () => {