Files
pn-new-crm/tests/unit/validators.test.ts
Matt 67d7e6e3d5
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00

346 lines
13 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { createClientSchema, updateClientSchema } from '@/lib/validators/clients';
import { createInterestSchema, updateInterestSchema, 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';
// ─── 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<string, unknown>).fieldType).toBeUndefined();
}
// If it fails that's also acceptable (strict mode), but the key thing is
// it cannot be used to mutate fieldType.
});
});