import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests, interestBerths } from '@/lib/db/schema/interests'; import { berthTenancies } from '@/lib/db/schema/tenancies'; import { systemSettings } from '@/lib/db/schema/system'; import { autoCreatePendingTenancies } from '@/lib/services/berth-tenancies.service'; import { enableTenanciesModule, isTenanciesModuleEnabled, } from '@/lib/services/tenancies-module.service'; import { makeBerth, makeClient, makePort, makeYacht } from '../helpers/factories'; async function seedInterestWithBundleBerths( portId: string, bundleCount: number, ): Promise<{ interestId: string; clientId: string; yachtId: string; berthIds: string[] }> { const client = await makeClient({ portId }); const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id }); const [interest] = await db .insert(interests) .values({ portId, clientId: client.id, yachtId: yacht.id, pipelineStage: 'reservation', outcome: 'open', }) .returning(); const berthIds: string[] = []; for (let i = 0; i < bundleCount; i++) { const b = await makeBerth({ portId }); berthIds.push(b.id); await db.insert(interestBerths).values({ interestId: interest!.id, berthId: b.id, isInEoiBundle: true, isPrimary: i === 0, isSpecificInterest: true, }); } return { interestId: interest!.id, clientId: client.id, yachtId: yacht.id, berthIds }; } async function disableModule(portId: string): Promise { await db .insert(systemSettings) .values({ key: 'tenancies_module_enabled', portId, value: false }) .onConflictDoUpdate({ target: [systemSettings.key, systemSettings.portId], set: { value: false, updatedAt: new Date() }, }); } describe('autoCreatePendingTenancies', () => { let portId: string; beforeEach(async () => { const port = await makePort(); portId = port.id; // Start each test with the module explicitly disabled so the lazy // auto-enable path doesn't taint the next test's port. await disableModule(portId); }); afterEach(async () => { // Make sure no stale advisory locks from a failed test linger. }); it('mints one pending tenancy per in-bundle berth', async () => { await enableTenanciesModule(portId); const { interestId, berthIds } = await seedInterestWithBundleBerths(portId, 3); const result = await autoCreatePendingTenancies(portId, interestId, { signedAt: new Date('2026-01-15'), sourceDocumentId: 'doc-fixture-123', signedFileId: null, }); expect(result).toHaveLength(3); expect(result.every((r) => r.status === 'pending')).toBe(true); expect(result.every((r) => r.interestId === interestId)).toBe(true); expect(result.every((r) => r.contractFileId === null)).toBe(true); expect(result.map((r) => r.berthId).sort()).toEqual(berthIds.sort()); }); it('skips berths that already have a live tenancy (idempotent under retry)', async () => { await enableTenanciesModule(portId); const { interestId, berthIds } = await seedInterestWithBundleBerths(portId, 2); const first = await autoCreatePendingTenancies(portId, interestId, { signedAt: new Date(), sourceDocumentId: 'doc-1', signedFileId: null, }); expect(first).toHaveLength(2); const replay = await autoCreatePendingTenancies(portId, interestId, { signedAt: new Date(), sourceDocumentId: 'doc-1', signedFileId: null, }); expect(replay).toHaveLength(0); const allRows = await db .select() .from(berthTenancies) .where(and(eq(berthTenancies.portId, portId), eq(berthTenancies.interestId, interestId))); expect(allRows).toHaveLength(2); expect(allRows.map((r) => r.berthId).sort()).toEqual(berthIds.sort()); }); it('returns empty when the interest has no in-bundle berths', async () => { await enableTenanciesModule(portId); const client = await makeClient({ portId }); const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id }); const [interest] = await db .insert(interests) .values({ portId, clientId: client.id, yachtId: yacht.id, pipelineStage: 'reservation', outcome: 'open', }) .returning(); const result = await autoCreatePendingTenancies(portId, interest!.id, { signedAt: new Date(), sourceDocumentId: 'doc-empty', signedFileId: null, }); expect(result).toEqual([]); }); it('does not mint when the interest is missing (deleted before webhook fires)', async () => { await enableTenanciesModule(portId); const result = await autoCreatePendingTenancies(portId, 'nonexistent-interest', { signedAt: new Date(), sourceDocumentId: 'doc-x', signedFileId: null, }); expect(result).toEqual([]); }); }); describe('Tenancies module enable-on-createPending (audit M29: explicit-disable wins)', () => { it('does NOT re-enable a module an admin explicitly disabled (M29)', async () => { const port = await makePort(); const portId = port.id; await disableModule(portId); expect(await isTenanciesModuleEnabled(portId)).toBe(false); const client = await makeClient({ portId }); const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id }); const berth = await makeBerth({ portId }); const { createPending } = await import('@/lib/services/berth-tenancies.service'); await createPending( portId, { berthId: berth.id, clientId: client.id, yachtId: yacht.id, startDate: new Date(), }, { userId: 'system', portId, ipAddress: '0.0.0.0', userAgent: 'test' }, ); // Audit M29: a manual createPending (or a Reservation-Agreement webhook) // must respect an EXPLICIT `false` and not silently flip the module back // on. The lazy auto-surface applies only when the setting is UNSET. expect(await isTenanciesModuleEnabled(portId)).toBe(false); }); });