/** * recommendations.service yacht dimensions source integration tests. * * Covers: * - generateRecommendations reads yacht dimensions from yachts table (not client) when interest.yachtId is set * - Falls back to null dimensions when interest has no yacht linked * - Correctly guards against cross-tenant access (yacht.portId must match) * - Recommendations are scored based on yacht dimensions from yachts table * * Uses dynamic imports so env is loaded before service modules touch `db`. */ import { describe, it, expect, beforeAll } from 'vitest'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; describe('recommendations.service — yacht dimensions source', () => { let generateRecommendations: typeof import('@/lib/services/recommendations').generateRecommendations; let makePort: typeof import('../helpers/factories').makePort; let makeClient: typeof import('../helpers/factories').makeClient; let makeYacht: typeof import('../helpers/factories').makeYacht; let makeBerth: typeof import('../helpers/factories').makeBerth; let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta; beforeAll(async () => { const svc = await import('@/lib/services/recommendations'); generateRecommendations = svc.generateRecommendations; const factories = await import('../helpers/factories'); makePort = factories.makePort; makeClient = factories.makeClient; makeYacht = factories.makeYacht; makeBerth = factories.makeBerth; makeAuditMeta = factories.makeAuditMeta; }); it('reads yacht dimensions from yachts table (not client) when interest.yachtId is set', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, overrides: { lengthFt: '45', widthFt: '14', draftFt: '7', }, }); // Create a berth that matches the yacht dimensions // eslint-disable-next-line @typescript-eslint/no-unused-vars const berth = await makeBerth({ portId: port.id, overrides: { lengthFt: '50', widthFt: '15', draftFt: '8', }, }); // Insert interest with yachtId const [interest] = await db .insert(interests) .values({ portId: port.id, clientId: client.id, yachtId: yacht.id, pipelineStage: 'open', }) .returning(); const recommendations = await generateRecommendations( interest!.id, port.id, makeAuditMeta({ portId: port.id }), ); // Should have at least one recommendation with scoring based on yacht dims expect(recommendations.length).toBeGreaterThan(0); const rec = recommendations[0]; expect(rec).toBeDefined(); // length_fit, beam_fit, draft_fit should be in reasons const reasons = rec!.matchReasons as Record; expect(reasons).toHaveProperty('length_fit'); expect(reasons).toHaveProperty('beam_fit'); expect(reasons).toHaveProperty('draft_fit'); }); it('falls back to null dimensions when interest has no yacht linked', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); // Create a berth // eslint-disable-next-line @typescript-eslint/no-unused-vars const berth = await makeBerth({ portId: port.id, overrides: { lengthFt: '50', widthFt: '15', draftFt: '8', }, }); // Insert interest without yachtId const [interest] = await db .insert(interests) .values({ portId: port.id, clientId: client.id, yachtId: null, pipelineStage: 'open', }) .returning(); const recommendations = await generateRecommendations( interest!.id, port.id, makeAuditMeta({ portId: port.id }), ); // Should still have recommendations, but with fallback scoring (no_dimensions reason) expect(recommendations.length).toBeGreaterThan(0); const rec = recommendations[0]; const reasons = rec!.matchReasons as Record; // When no yacht dimensions, the score should use the fallback (no_dimensions) expect(reasons).toHaveProperty('no_dimensions'); }); it('falls back to null dimensions when yacht is cross-tenant (safety)', async () => { const port1 = await makePort(); const port2 = await makePort(); const client = await makeClient({ portId: port1.id }); // Create yacht in port2 const yachtInPort2 = await makeYacht({ portId: port2.id, ownerType: 'client', ownerId: client.id, overrides: { lengthFt: '45', widthFt: '14', draftFt: '7', }, }); // Create berth in port1 // eslint-disable-next-line @typescript-eslint/no-unused-vars const berth = await makeBerth({ portId: port1.id, overrides: { lengthFt: '50', widthFt: '15', draftFt: '8', }, }); // Insert interest in port1 but with yachtId pointing to port2's yacht // This simulates a cross-tenant safety issue const [interest] = await db .insert(interests) .values({ portId: port1.id, clientId: client.id, yachtId: yachtInPort2.id, pipelineStage: 'open', }) .returning(); const recommendations = await generateRecommendations( interest!.id, port1.id, // Asking for port1 recommendations makeAuditMeta({ portId: port1.id }), ); // The yacht lookup should fail due to portId mismatch, so fall back to null dims expect(recommendations.length).toBeGreaterThan(0); const rec = recommendations[0]; const reasons = rec!.matchReasons as Record; // Should use no_dimensions fallback because yacht from another port is not found expect(reasons).toHaveProperty('no_dimensions'); }); it('correctly scores berths based on yacht dimension matches', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); // Create a 40ft yacht const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, overrides: { lengthFt: '40', widthFt: '12', draftFt: '6', }, }); // Create two berths: one that fits perfectly, one that's too small // eslint-disable-next-line @typescript-eslint/no-unused-vars const perfectFitBerth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'PERFECT', lengthFt: '42', // within 20% of 40 widthFt: '13', // within 30% of 12 draftFt: '7', // sufficient for 6 }, }); // eslint-disable-next-line @typescript-eslint/no-unused-vars const tooSmallBerth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'SMALL', lengthFt: '35', // smaller than yacht widthFt: '10', // smaller than yacht draftFt: '5', // smaller than yacht }, }); // Insert interest with yacht const [interest] = await db .insert(interests) .values({ portId: port.id, clientId: client.id, yachtId: yacht.id, pipelineStage: 'open', }) .returning(); const recommendations = await generateRecommendations( interest!.id, port.id, makeAuditMeta({ portId: port.id }), ); // Should have 2 recommendations expect(recommendations.length).toBe(2); // The first (highest score) should be the perfect-fit berth const topRec = recommendations[0]; expect(topRec!.mooringNumber).toBe('PERFECT'); const topReasons = topRec!.matchReasons as Record; expect(topReasons.length_fit).toBe(100); // perfect fit expect(topReasons.beam_fit).toBe(100); // perfect fit expect(topReasons.draft_fit).toBe(100); // perfect fit // The second should be the too-small berth with 0 scores const bottomRec = recommendations[1]; expect(bottomRec!.mooringNumber).toBe('SMALL'); const bottomReasons = bottomRec!.matchReasons as Record; expect(bottomReasons.length_fit).toBe(0); // too small expect(bottomReasons.beam_fit).toBe(0); // too small expect(bottomReasons.draft_fit).toBe(0); // too small }); });