fix(b4-bugs): external-EOI cache collision + stage-gate regression test + search popover opacity

Three B4 bug fixes shipped together:

- **#4 Upload-signed-copy blank body** — ExternalEoiUploadDialog used
  queryKey=['interests', interestId] but didn't unwrap the {data} envelope
  while the parent InterestDetail (same key) does, so opening the dialog
  clobbered the cache with a wrapped shape and blanked the detail page
  ("Unknown Client" + empty tab body). Dialog now unwraps to match.

- **#2 Legacy-stage canonicalization regression test** — new integration
  test locks in the external-EOI advance gate: canonical pre-EOI stages
  (enquiry/qualified/nurturing) advance to 'eoi' on upload; at-or-past-EOI
  stages stay put while metadata still writes. 7/7 passing. Backfill
  script intentionally not shipped — dev DB is test data, prod cutover
  is manual.

- **#3 Global-search dropdown translucent rows** — defensive opaque
  background on the popover wrapper (bg-white dark:bg-popover) guards
  against the subtle transparency UAT captured on the Berths page.
  Live-browser repro still needed to identify the exact bleeding row;
  this defense makes the surface unambiguously solid in light mode
  regardless of which class wins tailwind-merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:59:25 +02:00
parent 13834afa46
commit 65b92cace1
3 changed files with 171 additions and 7 deletions

View File

@@ -0,0 +1,151 @@
/**
* 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 { 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>,
) {
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' }],
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();
},
);
});