264 lines
8.3 KiB
TypeScript
264 lines
8.3 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, number>;
|
||
|
|
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<string, number>;
|
||
|
|
// 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<string, number>;
|
||
|
|
// 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<string, number>;
|
||
|
|
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<string, number>;
|
||
|
|
expect(bottomReasons.length_fit).toBe(0); // too small
|
||
|
|
expect(bottomReasons.beam_fit).toBe(0); // too small
|
||
|
|
expect(bottomReasons.draft_fit).toBe(0); // too small
|
||
|
|
});
|
||
|
|
});
|