import { describe, it, expect } from 'vitest'; import { createClientSchema, updateClientSchema } from '@/lib/validators/clients'; import { createInterestSchema, changeStageSchema } from '@/lib/validators/interests'; import { updateBerthSchema, updateBerthStatusSchema } from '@/lib/validators/berths'; import { createInvoiceSchema } from '@/lib/validators/invoices'; import { createWebhookSchema, updateWebhookSchema } from '@/lib/validators/webhooks'; import { createFieldSchema, updateFieldSchema } from '@/lib/validators/custom-fields'; import { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts'; import { createCompanySchema } from '@/lib/validators/companies'; import { addMembershipSchema } from '@/lib/validators/company-memberships'; import { createPendingSchema } from '@/lib/validators/reservations'; // ─── Client schemas ─────────────────────────────────────────────────────────── describe('createClientSchema', () => { const validClient = { fullName: 'Alice Smith', contacts: [{ channel: 'email' as const, value: 'alice@example.com' }], }; it('accepts a valid minimal client', () => { expect(createClientSchema.safeParse(validClient).success).toBe(true); }); it('rejects empty fullName', () => { const result = createClientSchema.safeParse({ ...validClient, fullName: '' }); expect(result.success).toBe(false); }); it('rejects when contacts array is empty', () => { const result = createClientSchema.safeParse({ ...validClient, contacts: [] }); expect(result.success).toBe(false); if (!result.success) { const paths = result.error.issues.map((i) => i.path.join('.')); expect(paths).toContain('contacts'); } }); it('rejects invalid contact channel', () => { const result = createClientSchema.safeParse({ ...validClient, contacts: [{ channel: 'fax', value: '1234' }], }); expect(result.success).toBe(false); }); it('rejects invalid email in contact value', () => { // channel=email doesn't mandate email format at schema level (value is just string.min(1)) // But empty value is rejected const result = createClientSchema.safeParse({ ...validClient, contacts: [{ channel: 'email' as const, value: '' }], }); expect(result.success).toBe(false); }); it('rejects invalid source enum', () => { const result = createClientSchema.safeParse({ ...validClient, source: 'unknown' }); expect(result.success).toBe(false); }); it('accepts optional fields', () => { const result = createClientSchema.safeParse({ ...validClient, companyName: 'ACME', nationality: 'AU', source: 'manual' as const, }); expect(result.success).toBe(true); }); }); describe('updateClientSchema (partial)', () => { it('accepts empty object (all optional)', () => { expect(updateClientSchema.safeParse({}).success).toBe(true); }); it('rejects fullName: empty string even in update', () => { const result = updateClientSchema.safeParse({ fullName: '' }); expect(result.success).toBe(false); }); }); // ─── Interest schemas ───────────────────────────────────────────────────────── describe('createInterestSchema', () => { const validInterest = { clientId: 'client-uuid-1' }; it('accepts a valid minimal interest', () => { expect(createInterestSchema.safeParse(validInterest).success).toBe(true); }); it('rejects empty clientId', () => { const result = createInterestSchema.safeParse({ clientId: '' }); expect(result.success).toBe(false); }); it('rejects invalid pipelineStage', () => { const result = createInterestSchema.safeParse({ clientId: 'c1', pipelineStage: 'unknown_stage', }); expect(result.success).toBe(false); }); it('accepts all valid pipeline stages', () => { const stages = [ 'open', 'details_sent', 'in_communication', 'visited', 'signed_eoi_nda', 'deposit_10pct', 'contract', 'completed', ]; for (const stage of stages) { const result = createInterestSchema.safeParse({ clientId: 'c1', pipelineStage: stage }); expect(result.success, `stage "${stage}" should be valid`).toBe(true); } }); it('rejects reminderDays < 1', () => { const result = createInterestSchema.safeParse({ clientId: 'c1', reminderDays: 0 }); expect(result.success).toBe(false); }); }); describe('changeStageSchema', () => { it('accepts a valid stage', () => { expect(changeStageSchema.safeParse({ pipelineStage: 'visited' }).success).toBe(true); }); it('rejects invalid stage', () => { expect(changeStageSchema.safeParse({ pipelineStage: 'bogus' }).success).toBe(false); }); }); // ─── Berth schemas ──────────────────────────────────────────────────────────── describe('updateBerthSchema', () => { it('accepts empty object (all optional)', () => { expect(updateBerthSchema.safeParse({}).success).toBe(true); }); it('accepts valid tenure type', () => { expect(updateBerthSchema.safeParse({ tenureType: 'permanent' }).success).toBe(true); }); it('rejects invalid tenure type', () => { expect(updateBerthSchema.safeParse({ tenureType: 'lease' }).success).toBe(false); }); }); describe('updateBerthStatusSchema', () => { it('accepts valid status with reason', () => { expect( updateBerthStatusSchema.safeParse({ status: 'available', reason: 'Freed up' }).success, ).toBe(true); }); it('rejects invalid status', () => { expect( updateBerthStatusSchema.safeParse({ status: 'occupied', reason: 'reason' }).success, ).toBe(false); }); it('rejects missing reason', () => { const result = updateBerthStatusSchema.safeParse({ status: 'available', reason: '' }); expect(result.success).toBe(false); if (!result.success) { const paths = result.error.issues.map((i) => i.path.join('.')); expect(paths).toContain('reason'); } }); }); // ─── Invoice schemas ────────────────────────────────────────────────────────── describe('createInvoiceSchema', () => { const validInvoice = { clientName: 'Bob', dueDate: '2026-06-01', lineItems: [{ description: 'Berth fee', quantity: 1, unitPrice: 5000 }], }; it('accepts a valid invoice with line items', () => { expect(createInvoiceSchema.safeParse(validInvoice).success).toBe(true); }); it('accepts invoice with only expenseIds', () => { const result = createInvoiceSchema.safeParse({ clientName: 'Bob', dueDate: '2026-06-01', expenseIds: ['exp-1'], }); expect(result.success).toBe(true); }); it('rejects invoice with neither lineItems nor expenseIds', () => { const result = createInvoiceSchema.safeParse({ clientName: 'Bob', dueDate: '2026-06-01' }); expect(result.success).toBe(false); }); it('rejects empty clientName', () => { const result = createInvoiceSchema.safeParse({ ...validInvoice, clientName: '' }); expect(result.success).toBe(false); }); it('rejects invalid billingEmail', () => { const result = createInvoiceSchema.safeParse({ ...validInvoice, billingEmail: 'not-an-email' }); expect(result.success).toBe(false); }); it('rejects currency that is not 3 chars', () => { const result = createInvoiceSchema.safeParse({ ...validInvoice, currency: 'USDX' }); expect(result.success).toBe(false); }); it('rejects negative unit price', () => { const result = createInvoiceSchema.safeParse({ ...validInvoice, lineItems: [{ description: 'Fee', quantity: 1, unitPrice: -1 }], }); expect(result.success).toBe(false); }); }); // ─── Webhook schemas ────────────────────────────────────────────────────────── describe('createWebhookSchema', () => { const validWebhook = { name: 'My Webhook', url: 'https://example.com/hook', events: ['client.created'], }; it('accepts a valid webhook', () => { expect(createWebhookSchema.safeParse(validWebhook).success).toBe(true); }); it('rejects http URL (must be HTTPS)', () => { const result = createWebhookSchema.safeParse({ ...validWebhook, url: 'http://example.com/hook', }); expect(result.success).toBe(false); if (!result.success) { const messages = result.error.issues.map((i) => i.message); expect(messages.some((m) => m.toLowerCase().includes('https'))).toBe(true); } }); it('rejects non-URL string', () => { const result = createWebhookSchema.safeParse({ ...validWebhook, url: 'not a url' }); expect(result.success).toBe(false); }); it('rejects empty events array', () => { const result = createWebhookSchema.safeParse({ ...validWebhook, events: [] }); expect(result.success).toBe(false); if (!result.success) { const paths = result.error.issues.map((i) => i.path.join('.')); expect(paths).toContain('events'); } }); it('rejects unknown event name', () => { const result = createWebhookSchema.safeParse({ ...validWebhook, events: ['unknown.event'] }); expect(result.success).toBe(false); }); it('rejects empty webhook name', () => { const result = createWebhookSchema.safeParse({ ...validWebhook, name: '' }); expect(result.success).toBe(false); }); }); describe('updateWebhookSchema', () => { it('accepts empty object (all optional)', () => { expect(updateWebhookSchema.safeParse({}).success).toBe(true); }); it('rejects http URL in update too', () => { const result = updateWebhookSchema.safeParse({ url: 'http://example.com/hook' }); expect(result.success).toBe(false); }); }); // ─── Custom field schemas ───────────────────────────────────────────────────── describe('createFieldSchema', () => { const validTextField = { entityType: 'client', fieldName: 'preferred_marina', fieldLabel: 'Preferred Marina', fieldType: 'text', }; it('accepts a valid text field', () => { expect(createFieldSchema.safeParse(validTextField).success).toBe(true); }); it('rejects fieldName that is not snake_case', () => { const result = createFieldSchema.safeParse({ ...validTextField, fieldName: 'PreferredMarina' }); expect(result.success).toBe(false); if (!result.success) { const paths = result.error.issues.map((i) => i.path.join('.')); expect(paths).toContain('fieldName'); } }); it('rejects fieldName with spaces', () => { const result = createFieldSchema.safeParse({ ...validTextField, fieldName: 'preferred marina', }); expect(result.success).toBe(false); }); it('accepts select type with selectOptions', () => { const result = createFieldSchema.safeParse({ ...validTextField, fieldType: 'select', selectOptions: ['Option A', 'Option B'], }); expect(result.success).toBe(true); }); it('rejects select type without selectOptions', () => { const result = createFieldSchema.safeParse({ ...validTextField, fieldType: 'select' }); expect(result.success).toBe(false); if (!result.success) { const paths = result.error.issues.map((i) => i.path.join('.')); expect(paths).toContain('selectOptions'); } }); it('rejects invalid fieldType', () => { const result = createFieldSchema.safeParse({ ...validTextField, fieldType: 'json' }); expect(result.success).toBe(false); }); it('rejects invalid entityType', () => { const result = createFieldSchema.safeParse({ ...validTextField, entityType: 'invoice' }); expect(result.success).toBe(false); }); }); describe('updateFieldSchema', () => { it('accepts empty object (all optional)', () => { expect(updateFieldSchema.safeParse({}).success).toBe(true); }); it('accepts valid update with fieldLabel', () => { expect(updateFieldSchema.safeParse({ fieldLabel: 'New Label' }).success).toBe(true); }); it('does NOT accept fieldType (immutability by omission)', () => { // fieldType is omitted from the schema — it should be stripped or cause a strict failure // With Zod default (strip mode), unknown keys are stripped and parse succeeds. // The important check is that the parsed output does NOT include fieldType. const result = updateFieldSchema.safeParse({ fieldType: 'number' }); if (result.success) { // fieldType should be stripped from output expect((result.data as Record).fieldType).toBeUndefined(); } // If it fails that's also acceptable (strict mode), but the key thing is // it cannot be used to mutate fieldType. }); }); // ─── Yacht schemas ──────────────────────────────────────────────────────────── 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); }); }); // ─── Company schemas ────────────────────────────────────────────────────────── describe('createCompanySchema', () => { it('rejects empty name', () => { const result = createCompanySchema.safeParse({ name: '' }); expect(result.success).toBe(false); }); it('rejects invalid billingEmail', () => { const result = createCompanySchema.safeParse({ name: 'Aegean Holdings', billingEmail: 'not-an-email', }); expect(result.success).toBe(false); }); it('accepts minimal valid input', () => { const result = createCompanySchema.safeParse({ name: 'Aegean Holdings' }); expect(result.success).toBe(true); }); it('accepts full valid input', () => { const result = createCompanySchema.safeParse({ name: 'Aegean Holdings', legalName: 'Aegean Holdings Ltd.', taxId: 'GR123456789', registrationNumber: 'REG-001', incorporationCountry: 'GR', incorporationDate: '2010-04-15', status: 'active', billingEmail: 'billing@aegean.example', notes: 'Longtime customer', tagIds: ['tag-1', 'tag-2'], }); expect(result.success).toBe(true); }); }); // ─── Company membership schemas ────────────────────────────────────────────── describe('addMembershipSchema', () => { const validInput = { clientId: 'client-uuid-1', role: 'director' as const, startDate: '2026-01-01', }; it('rejects missing clientId', () => { const result = addMembershipSchema.safeParse({ role: 'director', startDate: '2026-01-01', }); expect(result.success).toBe(false); }); it('rejects invalid role', () => { const result = addMembershipSchema.safeParse({ ...validInput, role: 'janitor' }); expect(result.success).toBe(false); }); it('accepts minimal valid input', () => { const result = addMembershipSchema.safeParse(validInput); expect(result.success).toBe(true); }); }); // ─── Reservation schemas ───────────────────────────────────────────────────── describe('createPendingSchema', () => { const validInput = { berthId: 'berth-1', clientId: 'client-1', yachtId: 'yacht-1', startDate: '2026-05-01', }; it('rejects missing berthId', () => { const result = createPendingSchema.safeParse({ clientId: validInput.clientId, yachtId: validInput.yachtId, startDate: validInput.startDate, }); expect(result.success).toBe(false); }); it('rejects missing clientId', () => { const result = createPendingSchema.safeParse({ berthId: validInput.berthId, yachtId: validInput.yachtId, startDate: validInput.startDate, }); expect(result.success).toBe(false); }); it('rejects missing yachtId', () => { const result = createPendingSchema.safeParse({ berthId: validInput.berthId, clientId: validInput.clientId, startDate: validInput.startDate, }); expect(result.success).toBe(false); }); it('accepts minimal valid input with default tenureType', () => { const result = createPendingSchema.safeParse(validInput); expect(result.success).toBe(true); if (result.success) { expect(result.data.tenureType).toBe('permanent'); } }); });