The Reservation and Contract tabs reused ExternalEoiUploadDialog, but the service hard-coded the EOI document type, status columns, stage target, and berth rule. A signed contract uploaded from the Contract tab filed as an `eoi`, flipped `eoi_status`, and advanced the stage to `eoi` - wrong doc kind, wrong sub-state, wrong stage. - external-eoi.service: UPLOAD_CONFIG keyed off docType (eoi | reservation | contract) parameterises documentType, file category, storage prefix, doc-status column, signed-date column, target stage, advance-from set, and berth rule. eoi_status is written only for docType=eoi. - route: parse docType from the form (default eoi). - dialog: docType prop; generalised copy; EOI-only UI (active-EOI replace banner, public-map flip, cancelActiveDocumentId) gated to docType=eoi. - reservation/contract tabs: pass docType; drop the coming-soon comments. - test: docType routing cases (reservation -> reservation_agreement + reservation cols; contract -> contract + contract cols; eoi_status stays null on both; contract idempotent at/past contract stage). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
8.4 KiB
TypeScript
219 lines
8.4 KiB
TypeScript
/**
|
|
* 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<typeof import('@/lib/storage')>('@/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<typeof makeAuditMeta>,
|
|
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');
|
|
});
|
|
});
|
|
});
|