/** * Tests for validateCustomFieldValue — the private validation helper in * custom-fields.service.ts. Since it is not exported we test it via the * public setValues function, using vi.mock to avoid database calls. * All assertions focus on what error message (if any) is thrown. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; // ─── Mock database + dependencies ──────────────────────────────────────────── vi.mock('@/lib/db', () => ({ db: { query: { customFieldDefinitions: { findMany: vi.fn(), findFirst: vi.fn() }, // Entity-port-scope checks added by the security fix; default to a // truthy row so existing assertions still focus on validation logic. clients: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) }, interests: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) }, berths: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) }, yachts: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) }, companies: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) }, }, insert: vi.fn(), update: vi.fn(), delete: vi.fn(), select: vi.fn(), }, })); vi.mock('@/lib/db/schema/clients', () => ({ clients: {} })); vi.mock('@/lib/db/schema/interests', () => ({ interests: {} })); vi.mock('@/lib/db/schema/berths', () => ({ berths: {} })); vi.mock('@/lib/db/schema/yachts', () => ({ yachts: {} })); vi.mock('@/lib/db/schema/companies', () => ({ companies: {} })); vi.mock('@/lib/audit', () => ({ createAuditLog: vi.fn().mockResolvedValue(undefined), })); vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn() }, })); vi.mock('@/lib/db/schema/system', () => ({ customFieldDefinitions: {}, customFieldValues: {}, })); // next/server is not available in vitest node environment vi.mock('next/server', () => ({ NextResponse: { json: vi.fn(), }, })); import { setValues } from '@/lib/services/custom-fields.service'; import { db } from '@/lib/db'; import { ValidationError } from '@/lib/errors'; // ─── Helper to build a minimal CustomFieldDefinition ───────────────────────── function makeDefinition( fieldType: string, extras: { isRequired?: boolean; selectOptions?: string[] } = {}, ) { return { id: 'field-1', portId: 'port-1', entityType: 'client', fieldName: 'test_field', fieldLabel: 'Test Field', fieldType, selectOptions: extras.selectOptions ?? null, isRequired: extras.isRequired ?? false, sortOrder: 0, createdAt: new Date(), }; } const AUDIT_META = { userId: 'user-1', portId: 'port-1', ipAddress: '127.0.0.1', userAgent: 'test', }; beforeEach(() => { vi.clearAllMocks(); // Default: no existing values, upsert succeeds const insertChain = { values: vi.fn().mockReturnThis(), onConflictDoUpdate: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([{ id: 'cfv-1' }]), }; (db.insert as ReturnType).mockReturnValue(insertChain); }); /** Convenience: call setValues with a single field/value pair. */ async function validate( fieldType: string, value: unknown, extras?: { isRequired?: boolean; selectOptions?: string[] }, ) { (db.query.customFieldDefinitions.findMany as ReturnType).mockResolvedValue([ makeDefinition(fieldType, extras), ]); return setValues('entity-1', 'port-1', 'user-1', [{ fieldId: 'field-1', value }], AUDIT_META); } // ─── text ───────────────────────────────────────────────────────────────────── describe('custom field validation — text', () => { it('accepts a string value', async () => { await expect(validate('text', 'hello')).resolves.toBeDefined(); }); it('rejects a number value', async () => { await expect(validate('text', 42)).rejects.toBeInstanceOf(ValidationError); }); it('rejects a boolean value', async () => { await expect(validate('text', true)).rejects.toBeInstanceOf(ValidationError); }); it('rejects a string longer than 1000 chars', async () => { await expect(validate('text', 'x'.repeat(1001))).rejects.toBeInstanceOf(ValidationError); }); }); // ─── number ────────────────────────────────────────────────────────────────── describe('custom field validation — number', () => { it('accepts a valid number', async () => { await expect(validate('number', 42)).resolves.toBeDefined(); }); it('accepts zero', async () => { await expect(validate('number', 0)).resolves.toBeDefined(); }); it('rejects a string', async () => { await expect(validate('number', '42')).rejects.toBeInstanceOf(ValidationError); }); it('rejects NaN', async () => { await expect(validate('number', NaN)).rejects.toBeInstanceOf(ValidationError); }); }); // ─── date ───────────────────────────────────────────────────────────────────── describe('custom field validation — date', () => { it('accepts a valid ISO date string', async () => { await expect(validate('date', '2026-06-15')).resolves.toBeDefined(); }); it('accepts a full ISO datetime string', async () => { await expect(validate('date', '2026-06-15T10:00:00.000Z')).resolves.toBeDefined(); }); it('rejects "not-a-date"', async () => { await expect(validate('date', 'not-a-date')).rejects.toBeInstanceOf(ValidationError); }); it('rejects a number', async () => { await expect(validate('date', 20260615)).rejects.toBeInstanceOf(ValidationError); }); }); // ─── boolean ───────────────────────────────────────────────────────────────── describe('custom field validation — boolean', () => { it('accepts true', async () => { await expect(validate('boolean', true)).resolves.toBeDefined(); }); it('accepts false', async () => { await expect(validate('boolean', false)).resolves.toBeDefined(); }); it('rejects the string "true"', async () => { await expect(validate('boolean', 'true')).rejects.toBeInstanceOf(ValidationError); }); it('rejects 1 (number)', async () => { await expect(validate('boolean', 1)).rejects.toBeInstanceOf(ValidationError); }); }); // ─── select ────────────────────────────────────────────────────────────────── describe('custom field validation — select', () => { const options = ['Small', 'Medium', 'Large']; it('accepts a valid option', async () => { await expect(validate('select', 'Small', { selectOptions: options })).resolves.toBeDefined(); }); it('rejects an option not in the list', async () => { await expect(validate('select', 'XL', { selectOptions: options })).rejects.toBeInstanceOf( ValidationError, ); }); it('error message lists the valid options', async () => { try { await validate('select', 'XL', { selectOptions: options }); expect.fail('Should have thrown'); } catch (err) { expect(err).toBeInstanceOf(ValidationError); // The service wraps the error in ValidationError with an errors array const ve = err as ValidationError; const messages = JSON.stringify(ve); expect(messages).toMatch(/Small|Medium|Large/); } }); }); // ─── required / non-required null handling ─────────────────────────────────── describe('custom field validation — required vs optional null', () => { it('required field: null value → throws ValidationError', async () => { await expect(validate('text', null, { isRequired: true })).rejects.toBeInstanceOf( ValidationError, ); }); it('required field: undefined value → throws ValidationError', async () => { await expect(validate('text', undefined, { isRequired: true })).rejects.toBeInstanceOf( ValidationError, ); }); it('non-required field: null value → succeeds (no error)', async () => { // null for non-required means "clear the value" — setValues will upsert null await expect(validate('text', null, { isRequired: false })).resolves.toBeDefined(); }); });