From 899e588a0cbca2968807f3dc386a79d3e17f909b Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Thu, 23 Apr 2026 23:31:29 +0200 Subject: [PATCH] feat(yachts): add zod validators + tests --- src/lib/validators/yachts.ts | 52 ++++++++++++++++++++++++++++ tests/unit/validators/yachts.test.ts | 49 ++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/lib/validators/yachts.ts create mode 100644 tests/unit/validators/yachts.test.ts diff --git a/src/lib/validators/yachts.ts b/src/lib/validators/yachts.ts new file mode 100644 index 0000000..6ea2970 --- /dev/null +++ b/src/lib/validators/yachts.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; +import { baseListQuerySchema } from '@/lib/api/route-helpers'; + +export const ownerRefSchema = z.object({ + type: z.enum(['client', 'company']), + id: z.string().min(1), +}); + +export const createYachtSchema = z.object({ + name: z.string().min(1).max(200), + hullNumber: z.string().optional(), + registration: z.string().optional(), + flag: z.string().optional(), + yearBuilt: z.number().int().min(1800).max(2100).optional(), + builder: z.string().optional(), + model: z.string().optional(), + hullMaterial: z.string().optional(), + lengthFt: z.string().optional(), + widthFt: z.string().optional(), + draftFt: z.string().optional(), + lengthM: z.string().optional(), + widthM: z.string().optional(), + draftM: z.string().optional(), + owner: ownerRefSchema, // required; yacht must have an owner + status: z.enum(['active', 'retired', 'sold_away']).optional().default('active'), + notes: z.string().optional(), + tagIds: z.array(z.string()).optional().default([]), +}); + +export const updateYachtSchema = createYachtSchema.partial().omit({ owner: true }); +// Owner changes go through /transfer, not PATCH. + +export const transferOwnershipSchema = z.object({ + newOwner: ownerRefSchema, + effectiveDate: z.coerce.date(), + transferReason: z + .enum(['sale', 'inheritance', 'gift', 'company_restructure', 'other']) + .optional(), + transferNotes: z.string().optional(), +}); + +export const listYachtsQuery = baseListQuerySchema.extend({ + ownerType: z.enum(['client', 'company']).optional(), + ownerId: z.string().optional(), + status: z.enum(['active', 'retired', 'sold_away']).optional(), + search: z.string().optional(), +}); + +export type CreateYachtInput = z.infer; +export type UpdateYachtInput = z.infer; +export type TransferOwnershipInput = z.infer; +export type ListYachtsInput = z.infer; diff --git a/tests/unit/validators/yachts.test.ts b/tests/unit/validators/yachts.test.ts new file mode 100644 index 0000000..81424d7 --- /dev/null +++ b/tests/unit/validators/yachts.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts'; + +describe('createYachtSchema', () => { + it('rejects empty name', () => { + const result = createYachtSchema.safeParse({ + name: '', + owner: { type: 'client', id: 'c1' }, + }); + expect(result.success).toBe(false); + }); + + it('requires owner', () => { + const result = createYachtSchema.safeParse({ name: 'Sea Breeze' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid yearBuilt', () => { + const result = createYachtSchema.safeParse({ + name: 'Sea Breeze', + owner: { type: 'client', id: 'c1' }, + yearBuilt: 1700, + }); + expect(result.success).toBe(false); + }); + + it('accepts minimal valid input', () => { + const result = createYachtSchema.safeParse({ + name: 'Sea Breeze', + owner: { type: 'client', id: 'c1' }, + }); + expect(result.success).toBe(true); + }); +}); + +describe('transferOwnershipSchema', () => { + it('requires newOwner + effectiveDate', () => { + expect(transferOwnershipSchema.safeParse({}).success).toBe(false); + }); + + it('accepts valid input', () => { + const result = transferOwnershipSchema.safeParse({ + newOwner: { type: 'company', id: 'co1' }, + effectiveDate: new Date(), + transferReason: 'sale', + }); + expect(result.success).toBe(true); + }); +});