/** * Integration test: external-EOI upload stage-advance gate. * * Regression coverage for the 2026-05-24 critical bug — the previous code * gated on the legacy 9-stage vocabulary (`open` / `details_sent` / * `in_communication` / `eoi_sent`), so uploads against canonical pre-EOI * stages (`enquiry`, `qualified`, `nurturing`) left `pipeline_stage` stuck * while `eoi_status` flipped to `signed`. * * Asserts: * - canonical pre-EOI stages (enquiry / qualified / nurturing) advance to `eoi` * - at-or-past-EOI stages (eoi / reservation / deposit_paid / contract) stay put * - document-metadata writes (dateEoiSigned, eoiStatus, eoiDocStatus) fire on every path * - the service return reports stageChanged + newStage accurately * * Storage is mocked to a no-op MinIO put so the service can run without S3. */ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { documents } from '@/lib/db/schema/documents'; import { eq } from 'drizzle-orm'; let uploadExternallySignedEoi: typeof import('@/lib/services/external-eoi.service').uploadExternallySignedEoi; let createInterest: typeof import('@/lib/services/interests.service').createInterest; let makePort: typeof import('../helpers/factories').makePort; let makeClient: typeof import('../helpers/factories').makeClient; let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta; beforeAll(async () => { const ext = await import('@/lib/services/external-eoi.service'); uploadExternallySignedEoi = ext.uploadExternallySignedEoi; const svc = await import('@/lib/services/interests.service'); createInterest = svc.createInterest; const factories = await import('../helpers/factories'); makePort = factories.makePort; makeClient = factories.makeClient; makeAuditMeta = factories.makeAuditMeta; }); describe('uploadExternallySignedEoi — stage-advance gate', () => { beforeEach(() => { vi.doMock('@/lib/storage', async () => { const real = await vi.importActual('@/lib/storage'); return { ...real, getStorageBackend: vi.fn(async () => ({ put: vi.fn(async () => undefined), get: vi.fn(), head: vi.fn(), delete: vi.fn(async () => undefined), listByPrefix: vi.fn(async () => []), presignUpload: vi.fn(async () => ''), presignDownload: vi.fn(async () => ''), })), }; }); }); afterEach(() => { vi.doUnmock('@/lib/storage'); }); async function makeInterest( stage: | 'enquiry' | 'qualified' | 'nurturing' | 'eoi' | 'reservation' | 'deposit_paid' | 'contract', ) { const port = await makePort(); const client = await makeClient({ portId: port.id }); const meta = makeAuditMeta({ portId: port.id }); const created = await createInterest( port.id, { clientId: client.id, pipelineStage: 'enquiry', reminderEnabled: false, tagIds: [] }, meta, ); // Set stage directly via DB to bypass the yacht-required gate on // changeInterestStage — this test focuses solely on the external-EOI // advance behaviour given an arbitrary starting stage, not on the // separate yacht-link gate that fronts manual stage transitions. if (stage !== 'enquiry') { await db.update(interests).set({ pipelineStage: stage }).where(eq(interests.id, created.id)); } return { port, client, meta, interest: created }; } async function uploadFakeEoi( interestId: string, portId: string, meta: ReturnType, docType?: 'eoi' | 'reservation' | 'contract', ) { return uploadExternallySignedEoi({ interestId, portId, fileData: { buffer: Buffer.from('%PDF-1.4\n%fake\n'), originalName: 'signed.pdf', mimeType: 'application/pdf', size: 16, }, signedAt: new Date('2026-05-24T10:00:00Z'), signatories: [{ name: 'Client', email: 'client@example.com', role: 'client' }], ...(docType ? { docType } : {}), meta, }); } it.each(['enquiry', 'qualified', 'nurturing'] as const)( 'advances %s → eoi', async (startStage) => { const { port, meta, interest } = await makeInterest(startStage); const result = await uploadFakeEoi(interest.id, port.id, meta); expect(result.stageChanged).toBe(true); expect(result.newStage).toBe('eoi'); const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) }); expect(row?.pipelineStage).toBe('eoi'); expect(row?.eoiStatus).toBe('signed'); expect(row?.eoiDocStatus).toBe('signed'); expect(row?.dateEoiSigned).toBeTruthy(); }, ); it.each(['eoi', 'reservation', 'deposit_paid', 'contract'] as const)( 'leaves %s untouched (metadata still writes)', async (startStage) => { const { port, meta, interest } = await makeInterest(startStage); const result = await uploadFakeEoi(interest.id, port.id, meta); expect(result.stageChanged).toBe(false); expect(result.newStage).toBe(startStage); const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) }); expect(row?.pipelineStage).toBe(startStage); expect(row?.eoiStatus).toBe('signed'); expect(row?.eoiDocStatus).toBe('signed'); expect(row?.dateEoiSigned).toBeTruthy(); }, ); // ── docType routing ──────────────────────────────────────────────────────── // Regression coverage for the paper-upload misroute: the reservation and // contract tabs reused this dialog, but the service hard-coded the EOI // documentType / status columns / stage target. A signed contract uploaded // from the Contract tab filed as an `eoi` and flipped `eoi_status` — wrong // doc kind, wrong sub-state, wrong stage. The service now keys all of that // off `docType`. describe('docType routing (reservation / contract)', () => { it('reservation: files as reservation_agreement, advances eoi → reservation, leaves eoi_status null', async () => { const { port, meta, interest } = await makeInterest('eoi'); const result = await uploadFakeEoi(interest.id, port.id, meta, 'reservation'); expect(result.stageChanged).toBe(true); expect(result.newStage).toBe('reservation'); const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) }); expect(row?.pipelineStage).toBe('reservation'); expect(row?.reservationDocStatus).toBe('signed'); expect(row?.dateReservationSigned).toBeTruthy(); // The EOI lifecycle column must NOT be touched by a reservation upload. expect(row?.eoiStatus).toBeNull(); expect(row?.eoiDocStatus).toBeNull(); const doc = await db.query.documents.findFirst({ where: eq(documents.id, result.documentId), }); expect(doc?.documentType).toBe('reservation_agreement'); }); it('contract: files as contract, advances reservation → contract, sets contract columns only', async () => { const { port, meta, interest } = await makeInterest('reservation'); const result = await uploadFakeEoi(interest.id, port.id, meta, 'contract'); expect(result.stageChanged).toBe(true); expect(result.newStage).toBe('contract'); const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) }); expect(row?.pipelineStage).toBe('contract'); expect(row?.contractDocStatus).toBe('signed'); expect(row?.dateContractSigned).toBeTruthy(); expect(row?.eoiStatus).toBeNull(); const doc = await db.query.documents.findFirst({ where: eq(documents.id, result.documentId), }); expect(doc?.documentType).toBe('contract'); }); it('contract: at-or-past contract stage stays put (idempotent), still files the doc', async () => { const { port, meta, interest } = await makeInterest('contract'); const result = await uploadFakeEoi(interest.id, port.id, meta, 'contract'); expect(result.stageChanged).toBe(false); expect(result.newStage).toBe('contract'); const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) }); expect(row?.pipelineStage).toBe('contract'); expect(row?.contractDocStatus).toBe('signed'); }); }); });