/** * Custom field integration tests. * * Verifies: * - Create a custom field definition (type: text) * - Attempt to update fieldType → ValidationError thrown * - Update fieldLabel → succeeds * - Set a value for an entity → value stored * - Get values for entity → returns value with definition * - Delete definition → values cascade deleted * * Skips gracefully when TEST_DATABASE_URL is not reachable. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { makeAuditMeta } from '../helpers/factories'; vi.mock('@/lib/audit', () => ({ createAuditLog: vi.fn().mockResolvedValue(undefined), })); const TEST_DB_URL = process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test'; let dbAvailable = false; beforeAll(async () => { try { const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1, idle_timeout: 3, connect_timeout: 3 }); await sql`SELECT 1`; await sql.end(); dbAvailable = true; } catch { console.warn('[custom-fields] Test database not available — skipping integration tests'); } }); function itDb(name: string, fn: () => Promise) { it(name, async () => { if (!dbAvailable) return; await fn(); }); } // ─── Helpers ───────────────────────────────────────────────────────────────── async function seedPort(): Promise { const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1 }); const portId = crypto.randomUUID(); await sql` INSERT INTO ports (id, name, slug, country, currency, timezone) VALUES (${portId}, 'Custom Fields Test Port', ${'cf-' + portId.slice(0, 8)}, 'AU', 'AUD', 'UTC') `; await sql.end(); return portId; } async function cleanupPort(portId: string): Promise { const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1 }); await sql`DELETE FROM ports WHERE id = ${portId}`; await sql.end(); } // ─── Definitions Tests ──────────────────────────────────────────────────────── describe('Custom Fields — Definitions', () => { let portId: string; const userId = crypto.randomUUID(); beforeAll(async () => { if (!dbAvailable) return; portId = await seedPort(); }); afterAll(async () => { if (!dbAvailable) return; await cleanupPort(portId); }); itDb('creates a custom field definition', async () => { const { createDefinition } = await import('@/lib/services/custom-fields.service'); const meta = makeAuditMeta({ portId, userId }); const def = await createDefinition( portId, userId, { entityType: 'client', fieldName: 'vessel_registration', fieldLabel: 'Vessel Registration', fieldType: 'text', isRequired: false, sortOrder: 0, }, meta, ); expect(def.id).toBeDefined(); expect(def.portId).toBe(portId); expect(def.fieldName).toBe('vessel_registration'); expect(def.fieldType).toBe('text'); }); itDb('creating duplicate fieldName for same entityType throws ConflictError', async () => { const { createDefinition } = await import('@/lib/services/custom-fields.service'); const { ConflictError } = await import('@/lib/errors'); const meta = makeAuditMeta({ portId, userId }); await createDefinition( portId, userId, { entityType: 'interest', fieldName: 'preferred_berth_area', fieldLabel: 'Preferred Berth Area', fieldType: 'text', isRequired: false, sortOrder: 0, }, meta, ); await expect( createDefinition( portId, userId, { entityType: 'interest', fieldName: 'preferred_berth_area', fieldLabel: 'Duplicate Label', fieldType: 'text', isRequired: false, sortOrder: 1, }, meta, ), ).rejects.toThrow(ConflictError); }); itDb('updateDefinition with fieldType property throws ValidationError', async () => { const { createDefinition, updateDefinition } = await import('@/lib/services/custom-fields.service'); const { ValidationError } = await import('@/lib/errors'); const meta = makeAuditMeta({ portId, userId }); const def = await createDefinition( portId, userId, { entityType: 'client', fieldName: 'immutable_type_field', fieldLabel: 'Immutable', fieldType: 'text', isRequired: false, sortOrder: 0, }, meta, ); // Cast bypasses TS — the service should guard against this at runtime. await expect( updateDefinition( portId, def.id, userId, { fieldType: 'number' } as unknown as Parameters[3], meta, ), ).rejects.toThrow(ValidationError); }); itDb('updateDefinition can change fieldLabel without error', async () => { const { createDefinition, updateDefinition } = await import('@/lib/services/custom-fields.service'); const meta = makeAuditMeta({ portId, userId }); const def = await createDefinition( portId, userId, { entityType: 'berth', fieldName: 'special_notes', fieldLabel: 'Notes', fieldType: 'text', isRequired: false, sortOrder: 0, }, meta, ); const updated = await updateDefinition( portId, def.id, userId, { fieldLabel: 'Special Notes' }, meta, ); expect(updated.fieldLabel).toBe('Special Notes'); expect(updated.fieldType).toBe('text'); }); }); // ─── Values Tests ───────────────────────────────────────────────────────────── describe('Custom Fields — Values', () => { let portId: string; const userId = crypto.randomUUID(); const entityId = crypto.randomUUID(); beforeAll(async () => { if (!dbAvailable) return; portId = await seedPort(); }); afterAll(async () => { if (!dbAvailable) return; await cleanupPort(portId); }); itDb('setValues stores a text value and getValues returns it with definition', async () => { const { createDefinition, setValues, getValues } = await import('@/lib/services/custom-fields.service'); const meta = makeAuditMeta({ portId, userId }); const def = await createDefinition( portId, userId, { entityType: 'client', fieldName: 'marina_membership', fieldLabel: 'Marina Membership', fieldType: 'text', isRequired: false, sortOrder: 0, }, meta, ); await setValues(entityId, portId, userId, [{ fieldId: def.id, value: 'GOLD-2024' }], meta); const result = await getValues(entityId, portId); const entry = result.find((r) => r.definition.id === def.id); expect(entry).toBeDefined(); expect(entry!.value).not.toBeNull(); // value is stored as jsonb — the raw stored value expect((entry!.value as Record).value).toBe('GOLD-2024'); }); itDb('setValues with wrong type throws ValidationError', async () => { const { createDefinition, setValues } = await import('@/lib/services/custom-fields.service'); const { ValidationError } = await import('@/lib/errors'); const meta = makeAuditMeta({ portId, userId }); const def = await createDefinition( portId, userId, { entityType: 'client', fieldName: 'year_joined', fieldLabel: 'Year Joined', fieldType: 'number', isRequired: false, sortOrder: 0, }, meta, ); await expect( setValues(entityId, portId, userId, [{ fieldId: def.id, value: 'not-a-number' }], meta), ).rejects.toThrow(ValidationError); }); itDb('deleteDefinition cascades to remove associated values', async () => { const { createDefinition, setValues, deleteDefinition, getValues } = await import('@/lib/services/custom-fields.service'); const meta = makeAuditMeta({ portId, userId }); const cascadeEntityId = crypto.randomUUID(); const def = await createDefinition( portId, userId, { entityType: 'client', fieldName: 'cascade_test_field', fieldLabel: 'Cascade Test', fieldType: 'text', isRequired: false, sortOrder: 0, }, meta, ); await setValues( cascadeEntityId, portId, userId, [{ fieldId: def.id, value: 'will-be-deleted' }], meta, ); // Verify the value exists const before = await getValues(cascadeEntityId, portId); expect(before.find((r) => r.definition.id === def.id)?.value).not.toBeNull(); const result = await deleteDefinition(portId, def.id, userId, meta); expect(result.deletedValueCount).toBeGreaterThanOrEqual(1); // Definition should no longer appear in getValues results const after = await getValues(cascadeEntityId, portId); expect(after.find((r) => r.definition.id === def.id)).toBeUndefined(); }); });