/** * Integration tests for the bulk berth price-reconcile service (CM-2 Part A). * * Uses the real filesystem storage backend (seeded below) + a real spec-sheet * PDF, so the full upload → store → re-parse → extract path is exercised end to * end with no storage mock. */ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { eq } from 'drizzle-orm'; import { beforeEach, describe, expect, it } from 'vitest'; import { listPriceReconciliation, applyBulkBerthPrices, } from '@/lib/services/berth-price-reconcile.service'; import { uploadBerthPdf } from '@/lib/services/berth-pdf.service'; import { db } from '@/lib/db'; import { berths } from '@/lib/db/schema/berths'; import { systemSettings } from '@/lib/db/schema/system'; import { makeBerth, makeFullPermissions, makePort } from '../helpers/factories'; import { makeMockCtx, makeMockRequest } from '../helpers/route-tester'; const A1_PDF = readFileSync(path.join(process.cwd(), 'berth_pdf_example/Berth_Spec_Sheet_A1.pdf')); beforeEach(async () => { await db .insert(systemSettings) .values({ key: 'storage_backend', value: 'filesystem', portId: null, updatedBy: null }) .onConflictDoNothing(); }); describe('listPriceReconciliation', () => { it('parses the main price for a berth with a PDF and flags one without', async () => { const port = await makePort(); const withPdf = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'A1' } }); // No-PDF berth — created for its 'no_pdf' row; the value isn't referenced. await makeBerth({ portId: port.id, overrides: { mooringNumber: 'Z9' } }); await uploadBerthPdf({ berthId: withPdf.id, portId: port.id, buffer: A1_PDF, fileName: 'Berth_Spec_Sheet_A1.pdf', uploadedBy: 'test-user', }); const rows = await listPriceReconciliation(port.id); const w = rows.find((r) => r.mooringNumber === 'A1'); const wo = rows.find((r) => r.mooringNumber === 'Z9'); expect(w?.parsedPrice).toBe(3880800); expect(w?.parsedCurrency).toBe('USD'); expect(w?.currentPrice).toBeNull(); expect(w?.status).toBe('changed'); // CRM price null → changed expect(wo?.status).toBe('no_pdf'); }); }); describe('applyBulkBerthPrices', () => { it('writes only approved, in-port berths and skips cross-port ids', async () => { const portA = await makePort(); const portB = await makePort(); const berthA = await makeBerth({ portId: portA.id, overrides: { mooringNumber: 'A1' } }); const berthB = await makeBerth({ portId: portB.id, overrides: { mooringNumber: 'A1' } }); const res = await applyBulkBerthPrices( portA.id, [ { berthId: berthA.id, price: 3880800, currency: 'USD' }, { berthId: berthB.id, price: 999, currency: 'USD' }, // foreign port → skipped ], 'test-user', ); expect(res.updated).toBe(1); const [a] = await db.select().from(berths).where(eq(berths.id, berthA.id)); expect(Number(a!.price)).toBe(3880800); expect(a!.priceCurrency).toBe('USD'); const [b] = await db.select().from(berths).where(eq(berths.id, berthB.id)); expect(b!.price).toBeNull(); // untouched }); }); describe('price-reconcile route handlers', () => { it('GET lists rows and POST apply writes the approved price', async () => { const { getHandler } = await import('@/app/api/v1/berths/price-reconcile/handlers'); const { postHandler } = await import('@/app/api/v1/berths/price-reconcile/apply/handlers'); const port = await makePort(); const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'A1' } }); await uploadBerthPdf({ berthId: berth.id, portId: port.id, buffer: A1_PDF, fileName: 'Berth_Spec_Sheet_A1.pdf', uploadedBy: 'test-user', }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const listRes = await getHandler( makeMockRequest('GET', 'http://t/api/v1/berths/price-reconcile'), ctx, {}, ); const listJson = (await listRes.json()) as { data: Array<{ mooringNumber: string; parsedPrice: number | null }>; }; expect(listJson.data.find((r) => r.mooringNumber === 'A1')?.parsedPrice).toBe(3880800); const applyRes = await postHandler( makeMockRequest('POST', 'http://t/api/v1/berths/price-reconcile/apply', { body: { approvals: [{ berthId: berth.id, price: 3880800, currency: 'USD' }] }, }), ctx, {}, ); const applyJson = (await applyRes.json()) as { data: { updated: number } }; expect(applyJson.data.updated).toBe(1); const [b] = await db.select().from(berths).where(eq(berths.id, berth.id)); expect(Number(b!.price)).toBe(3880800); }); });