diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index d84291b..87df389 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -1,114 +1,115 @@ /** * Test factory helpers. - * These return plain data objects — NOT database-inserted records. - * Safe to use whether or not a database is available. + * + * Two flavours: + * 1. Async DB-inserting factories (makePort, makeClient, makeBerth, makeYacht, + * makeCompany, ...) — insert a row via the app's `db` handle and return the + * inserted record. Use these from integration tests that need real FK / + * unique-index enforcement. They require DATABASE_URL to be reachable. + * 2. Plain-data helpers (makeAuditMeta, makeCreateClientInput, makeCreate*) + * — return in-memory objects suitable for unit tests with mocked `db`. */ +import { db } from '@/lib/db'; +import { ports, type NewPort, type Port } from '@/lib/db/schema/ports'; +import { clients, type NewClient, type Client } from '@/lib/db/schema/clients'; +import { berths, type NewBerth, type Berth } from '@/lib/db/schema/berths'; +import { yachts, yachtOwnershipHistory, type NewYacht, type Yacht } from '@/lib/db/schema/yachts'; +import { companies, type NewCompany, type Company } from '@/lib/db/schema/companies'; + +// ─── Port ──────────────────────────────────────────────────────────────────── + +export async function makePort(args?: { overrides?: Partial }): Promise { + const suffix = Math.random().toString(36).slice(2, 10); + const [port] = await db + .insert(ports) + .values({ + name: args?.overrides?.name ?? `Test Port ${suffix}`, + slug: args?.overrides?.slug ?? `test-port-${suffix}`, + ...args?.overrides, + }) + .returning(); + return port!; +} + // ─── Client ────────────────────────────────────────────────────────────────── -export interface ClientData { - id: string; +export async function makeClient(args: { portId: string; - fullName: string; - companyName: string | null; - nationality: string | null; - isProxy: boolean; - source: string | null; - yachtLengthFt: string | null; - yachtLengthM: string | null; - archivedAt: Date | null; - createdAt: Date; - updatedAt: Date; -} - -export function makeClient(overrides?: Partial): ClientData { - return { - id: crypto.randomUUID(), - portId: crypto.randomUUID(), - fullName: 'Test Client', - companyName: null, - nationality: null, - isProxy: false, - source: 'manual', - yachtLengthFt: null, - yachtLengthM: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -// ─── Interest ───────────────────────────────────────────────────────────────── - -export interface InterestData { - id: string; - portId: string; - clientId: string; - berthId: string | null; - pipelineStage: string; - leadCategory: string | null; - source: string | null; - eoiStatus: string | null; - contractStatus: string | null; - depositStatus: string | null; - notes: string | null; - archivedAt: Date | null; - createdAt: Date; - updatedAt: Date; -} - -export function makeInterest(overrides?: Partial): InterestData { - return { - id: crypto.randomUUID(), - portId: crypto.randomUUID(), - clientId: crypto.randomUUID(), - berthId: null, - pipelineStage: 'open', - leadCategory: null, - source: 'manual', - eoiStatus: null, - contractStatus: null, - depositStatus: null, - notes: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; + overrides?: Partial; +}): Promise { + const [client] = await db + .insert(clients) + .values({ + portId: args.portId, + fullName: args.overrides?.fullName ?? `Test Client ${Math.random().toString(36).slice(2, 8)}`, + ...args.overrides, + }) + .returning(); + return client!; } // ─── Berth ──────────────────────────────────────────────────────────────────── -export interface BerthData { - id: string; +export async function makeBerth(args: { portId: string; - mooringNumber: string; - status: string; - area: string | null; - lengthM: string | null; - price: string | null; - tenureType: string | null; - archivedAt: Date | null; - createdAt: Date; - updatedAt: Date; + overrides?: Partial; +}): Promise { + const [berth] = await db + .insert(berths) + .values({ + portId: args.portId, + mooringNumber: args.overrides?.mooringNumber ?? `B-${Math.random().toString(36).slice(2, 8)}`, + ...args.overrides, + }) + .returning(); + return berth!; } -export function makeBerth(overrides?: Partial): BerthData { - return { - id: crypto.randomUUID(), - portId: crypto.randomUUID(), - mooringNumber: `B-${Math.floor(Math.random() * 999) + 1}`, - status: 'available', - area: null, - lengthM: '12', - price: '50000', - tenureType: 'freehold', - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; +// ─── Yacht ─────────────────────────────────────────────────────────────────── + +export async function makeYacht(args: { + portId: string; + ownerType: 'client' | 'company'; + ownerId: string; + overrides?: Partial; +}): Promise { + const [yacht] = await db + .insert(yachts) + .values({ + portId: args.portId, + name: args.overrides?.name ?? `Yacht ${Math.random().toString(36).slice(2, 8)}`, + currentOwnerType: args.ownerType, + currentOwnerId: args.ownerId, + ...args.overrides, + }) + .returning(); + await db.insert(yachtOwnershipHistory).values({ + yachtId: yacht!.id, + ownerType: args.ownerType, + ownerId: args.ownerId, + startDate: new Date(), + endDate: null, + createdBy: 'test', + }); + return yacht!; +} + +// ─── Company ───────────────────────────────────────────────────────────────── + +export async function makeCompany(args: { + portId: string; + overrides?: Partial; +}): Promise { + const [company] = await db + .insert(companies) + .values({ + portId: args.portId, + name: args.overrides?.name ?? `Company ${Math.random().toString(36).slice(2, 8)}`, + ...args.overrides, + }) + .returning(); + return company!; } // ─── Webhook ────────────────────────────────────────────────────────────────── @@ -169,14 +170,50 @@ import type { RolePermissions } from '@/lib/db/schema/users'; export function makeFullPermissions(): RolePermissions { return { clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, - interests: { view: true, create: true, edit: true, delete: true, change_stage: true, generate_eoi: true, export: true }, + interests: { + view: true, + create: true, + edit: true, + delete: true, + change_stage: true, + generate_eoi: true, + export: true, + }, berths: { view: true, edit: true, import: true, manage_waiting_list: true }, - documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: true }, - expenses: { view: true, create: true, edit: true, delete: true, export: true, scan_receipt: true }, - invoices: { view: true, create: true, edit: true, delete: true, send: true, record_payment: true, export: true }, + documents: { + view: true, + create: true, + send_for_signing: true, + upload_signed: true, + delete: true, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: true, + export: true, + scan_receipt: true, + }, + invoices: { + view: true, + create: true, + edit: true, + delete: true, + send: true, + record_payment: true, + export: true, + }, files: { view: true, upload: true, delete: true, manage_folders: true }, email: { view: true, send: true, configure_account: true }, - reminders: { view_own: true, view_all: true, create: true, edit_own: true, edit_all: true, assign_others: true }, + reminders: { + view_own: true, + view_all: true, + create: true, + edit_own: true, + edit_all: true, + assign_others: true, + }, calendar: { connect: true, view_events: true }, reports: { view_dashboard: true, view_analytics: true, export: true }, document_templates: { view: true, generate: true, manage: true }, @@ -198,14 +235,50 @@ export function makeFullPermissions(): RolePermissions { export function makeViewerPermissions(): RolePermissions { return { clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false }, - interests: { view: true, create: false, edit: false, delete: false, change_stage: false, generate_eoi: false, export: false }, + interests: { + view: true, + create: false, + edit: false, + delete: false, + change_stage: false, + generate_eoi: false, + export: false, + }, berths: { view: true, edit: false, import: false, manage_waiting_list: false }, - documents: { view: true, create: false, send_for_signing: false, upload_signed: false, delete: false }, - expenses: { view: true, create: false, edit: false, delete: false, export: false, scan_receipt: false }, - invoices: { view: true, create: false, edit: false, delete: false, send: false, record_payment: false, export: false }, + documents: { + view: true, + create: false, + send_for_signing: false, + upload_signed: false, + delete: false, + }, + expenses: { + view: true, + create: false, + edit: false, + delete: false, + export: false, + scan_receipt: false, + }, + invoices: { + view: true, + create: false, + edit: false, + delete: false, + send: false, + record_payment: false, + export: false, + }, files: { view: true, upload: false, delete: false, manage_folders: false }, email: { view: true, send: false, configure_account: false }, - reminders: { view_own: true, view_all: false, create: false, edit_own: false, edit_all: false, assign_others: false }, + reminders: { + view_own: true, + view_all: false, + create: false, + edit_own: false, + edit_all: false, + assign_others: false, + }, calendar: { connect: false, view_events: true }, reports: { view_dashboard: true, view_analytics: false, export: false }, document_templates: { view: true, generate: false, manage: false }, @@ -227,14 +300,50 @@ export function makeViewerPermissions(): RolePermissions { export function makeSalesAgentPermissions(): RolePermissions { return { clients: { view: true, create: true, edit: true, delete: false, merge: false, export: false }, - interests: { view: true, create: true, edit: true, delete: false, change_stage: true, generate_eoi: true, export: false }, + interests: { + view: true, + create: true, + edit: true, + delete: false, + change_stage: true, + generate_eoi: true, + export: false, + }, berths: { view: true, edit: false, import: false, manage_waiting_list: false }, - documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: false }, - expenses: { view: true, create: true, edit: true, delete: false, export: false, scan_receipt: true }, - invoices: { view: true, create: false, edit: false, delete: false, send: false, record_payment: false, export: false }, + documents: { + view: true, + create: true, + send_for_signing: true, + upload_signed: true, + delete: false, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: false, + export: false, + scan_receipt: true, + }, + invoices: { + view: true, + create: false, + edit: false, + delete: false, + send: false, + record_payment: false, + export: false, + }, files: { view: true, upload: true, delete: false, manage_folders: false }, email: { view: true, send: true, configure_account: false }, - reminders: { view_own: true, view_all: false, create: true, edit_own: true, edit_all: false, assign_others: false }, + reminders: { + view_own: true, + view_all: false, + create: true, + edit_own: true, + edit_all: false, + assign_others: false, + }, calendar: { connect: true, view_events: true }, reports: { view_dashboard: true, view_analytics: false, export: false }, document_templates: { view: true, generate: true, manage: false }, @@ -256,14 +365,50 @@ export function makeSalesAgentPermissions(): RolePermissions { export function makeSalesManagerPermissions(): RolePermissions { return { clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, - interests: { view: true, create: true, edit: true, delete: true, change_stage: true, generate_eoi: true, export: true }, + interests: { + view: true, + create: true, + edit: true, + delete: true, + change_stage: true, + generate_eoi: true, + export: true, + }, berths: { view: true, edit: true, import: false, manage_waiting_list: true }, - documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: true }, - expenses: { view: true, create: true, edit: true, delete: true, export: true, scan_receipt: true }, - invoices: { view: true, create: true, edit: true, delete: false, send: true, record_payment: true, export: true }, + documents: { + view: true, + create: true, + send_for_signing: true, + upload_signed: true, + delete: true, + }, + expenses: { + view: true, + create: true, + edit: true, + delete: true, + export: true, + scan_receipt: true, + }, + invoices: { + view: true, + create: true, + edit: true, + delete: false, + send: true, + record_payment: true, + export: true, + }, files: { view: true, upload: true, delete: true, manage_folders: true }, email: { view: true, send: true, configure_account: false }, - reminders: { view_own: true, view_all: true, create: true, edit_own: true, edit_all: true, assign_others: true }, + reminders: { + view_own: true, + view_all: true, + create: true, + edit_own: true, + edit_all: true, + assign_others: true, + }, calendar: { connect: true, view_events: true }, reports: { view_dashboard: true, view_analytics: true, export: true }, document_templates: { view: true, generate: true, manage: false }, @@ -306,7 +451,15 @@ export function makeCreateClientInput(overrides?: { fullName?: string; portId?: /** Returns a minimal valid CreateInterestInput object. */ export function makeCreateInterestInput(overrides?: { clientId?: string; - pipelineStage?: 'open' | 'details_sent' | 'in_communication' | 'visited' | 'signed_eoi_nda' | 'deposit_10pct' | 'contract' | 'completed'; + pipelineStage?: + | 'open' + | 'details_sent' + | 'in_communication' + | 'visited' + | 'signed_eoi_nda' + | 'deposit_10pct' + | 'contract' + | 'completed'; }) { return { clientId: overrides?.clientId ?? crypto.randomUUID(), diff --git a/tests/integration/schema-constraints.test.ts b/tests/integration/schema-constraints.test.ts new file mode 100644 index 0000000..72f0750 --- /dev/null +++ b/tests/integration/schema-constraints.test.ts @@ -0,0 +1,169 @@ +/** + * Schema constraint integration tests. + * + * Verifies DB-level enforcement of: + * - Partial unique index idx_yoh_active (one active ownership row per yacht) + * - Partial unique index idx_br_active (one active reservation per berth) + * - Non-active reservations on the same berth are permitted + * - Case-insensitive company name uniqueness within a port + * - Same company name allowed across different ports + */ +import { describe, it, expect, beforeAll } from 'vitest'; + +import { db } from '@/lib/db'; +import { yachtOwnershipHistory } from '@/lib/db/schema/yachts'; +import { berthReservations } from '@/lib/db/schema/reservations'; +import { companies } from '@/lib/db/schema/companies'; +import { makeBerth, makeClient, makeCompany, makePort, makeYacht } from '../helpers/factories'; + +// ─── DB availability ───────────────────────────────────────────────────────── + +let dbAvailable = false; + +beforeAll(async () => { + try { + await db.execute(`SELECT 1`); + dbAvailable = true; + } catch (err) { + console.warn( + '[schema-constraints] DATABASE_URL not reachable — skipping integration tests', + err, + ); + } +}); + +function itDb(name: string, fn: () => Promise) { + it(name, async () => { + if (!dbAvailable) return; + await fn(); + }); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('schema constraints', () => { + itDb( + 'rejects a second active ownership row per yacht (partial unique idx_yoh_active)', + async () => { + const port = await makePort(); + const clientA = await makeClient({ portId: port.id }); + const clientB = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientA.id, + }); + // makeYacht already inserted one active (end_date IS NULL) ownership row. + + await expect( + db.insert(yachtOwnershipHistory).values({ + yachtId: yacht.id, + ownerType: 'client', + ownerId: clientB.id, + startDate: new Date(), + endDate: null, // another open row — should violate partial unique + createdBy: 'test', + }), + ).rejects.toThrow(/duplicate key|unique/i); + }, + ); + + itDb('rejects a second active reservation per berth (partial unique idx_br_active)', async () => { + const port = await makePort(); + const clientA = await makeClient({ portId: port.id }); + const clientB = await makeClient({ portId: port.id }); + const yachtA = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientA.id, + }); + const yachtB = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientB.id, + }); + const berth = await makeBerth({ portId: port.id }); + + await db.insert(berthReservations).values({ + berthId: berth.id, + portId: port.id, + clientId: clientA.id, + yachtId: yachtA.id, + status: 'active', + startDate: new Date(), + createdBy: 'test', + }); + + await expect( + db.insert(berthReservations).values({ + berthId: berth.id, + portId: port.id, + clientId: clientB.id, + yachtId: yachtB.id, + status: 'active', + startDate: new Date(), + createdBy: 'test', + }), + ).rejects.toThrow(/duplicate key|unique/i); + }); + + itDb( + 'allows multiple non-active reservations on the same berth (partial index ignores non-active)', + async () => { + const port = await makePort(); + const clientA = await makeClient({ portId: port.id }); + const yachtA = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientA.id, + }); + const berth = await makeBerth({ portId: port.id }); + + // Two ended reservations on same berth — both should succeed + // (partial index only constrains status='active'). + await expect( + db.insert(berthReservations).values([ + { + berthId: berth.id, + portId: port.id, + clientId: clientA.id, + yachtId: yachtA.id, + status: 'ended', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-06-30'), + createdBy: 'test', + }, + { + berthId: berth.id, + portId: port.id, + clientId: clientA.id, + yachtId: yachtA.id, + status: 'ended', + startDate: new Date('2024-07-01'), + endDate: new Date('2024-12-31'), + createdBy: 'test', + }, + ]), + ).resolves.toBeDefined(); + }, + ); + + itDb('enforces case-insensitive company name uniqueness per port', async () => { + const port = await makePort(); + await makeCompany({ portId: port.id, overrides: { name: 'Aegean Holdings' } }); + + await expect( + db.insert(companies).values({ portId: port.id, name: 'AEGEAN HOLDINGS' }), + ).rejects.toThrow(/duplicate key|unique/i); + }); + + itDb('allows same-name companies in different ports', async () => { + const portA = await makePort(); + const portB = await makePort(); + await makeCompany({ portId: portA.id, overrides: { name: 'Aegean Holdings' } }); + + await expect( + db.insert(companies).values({ portId: portB.id, name: 'Aegean Holdings' }), + ).resolves.toBeDefined(); + }); +});