import { describe, it, expect, vi, beforeAll } from 'vitest'; import { and, eq, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients, clientContacts } from '@/lib/db/schema/clients'; import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts'; import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { interests } from '@/lib/db/schema/interests'; import { makePort } from '../helpers/factories'; import { makeMockRequest } from '../helpers/route-tester'; // Mock fire-and-forget side-effects so the test doesn't hit Redis / external services. vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); vi.mock('@/lib/queue', () => ({ getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), })); vi.mock('@/lib/services/inquiry-notifications.service', () => ({ sendInquiryNotifications: vi.fn().mockResolvedValue(undefined), })); // The rate-limiter is keyed by IP header and is now redis-backed; entries // pexpire after the publicForm window (1h). Randomize the high octets so a // fresh test run doesn't collide with leftover redis state from a previous // run sharing the same redis instance. const IP_PREFIX = `10.${Math.floor(Math.random() * 200) + 10}`; let ipCounter = 1; function uniqueIp(): string { ipCounter += 1; return `${IP_PREFIX}.${Math.floor(ipCounter / 255) % 255}.${ipCounter % 255}`; } describe('POST /api/public/interests — trio creation', () => { let POST: typeof import('@/app/api/public/interests/route').POST; beforeAll(async () => { // Import after mocks are registered. const mod = await import('@/app/api/public/interests/route'); POST = mod.POST; }); it('creates client + yacht + interest atomically', async () => { const port = await makePort(); const email = `trio-client-${Math.random().toString(36).slice(2, 8)}@test.local`; const req = makeMockRequest('POST', `http://localhost/api/public/interests?portId=${port.id}`, { headers: { 'x-forwarded-for': uniqueIp() }, body: { firstName: 'Alice', lastName: 'Mariner', email, phone: '+10000000001', yacht: { name: 'Sea Star', lengthFt: 52, widthFt: 14, draftFt: 6, }, }, }); const res = await POST(req); expect(res.status).toBe(201); const body = await res.json(); const interestId: string = body.data.id; const [interest] = await db.select().from(interests).where(eq(interests.id, interestId)); expect(interest).toBeDefined(); expect(interest!.portId).toBe(port.id); expect(interest!.pipelineStage).toBe('open'); expect(interest!.yachtId).not.toBeNull(); expect(interest!.clientId).not.toBeNull(); // Yacht exists, owned by the client const [yacht] = await db.select().from(yachts).where(eq(yachts.id, interest!.yachtId!)); expect(yacht).toBeDefined(); expect(yacht!.name).toBe('Sea Star'); expect(yacht!.currentOwnerType).toBe('client'); expect(yacht!.currentOwnerId).toBe(interest!.clientId); // Ownership history row created const historyRows = await db .select() .from(yachtOwnershipHistory) .where(eq(yachtOwnershipHistory.yachtId, yacht!.id)); expect(historyRows.length).toBe(1); expect(historyRows[0]!.endDate).toBeNull(); expect(historyRows[0]!.ownerType).toBe('client'); expect(historyRows[0]!.ownerId).toBe(interest!.clientId); // Client has email + phone contacts const contacts = await db .select() .from(clientContacts) .where(eq(clientContacts.clientId, interest!.clientId)); expect(contacts.some((c) => c.channel === 'email' && c.value === email)).toBe(true); expect(contacts.some((c) => c.channel === 'phone' && c.value === '+10000000001')).toBe(true); }); it('creates client + company + membership + company-owned yacht + interest when company provided', async () => { const port = await makePort(); const email = `trio-co-${Math.random().toString(36).slice(2, 8)}@test.local`; const companyName = `Nautical Holdings ${Math.random().toString(36).slice(2, 8)}`; const req = makeMockRequest('POST', `http://localhost/api/public/interests?portId=${port.id}`, { headers: { 'x-forwarded-for': uniqueIp() }, body: { firstName: 'Bob', lastName: 'Director', email, phone: '+10000000002', yacht: { name: 'Corporate Cruiser', lengthFt: 80 }, company: { name: companyName, role: 'director', }, }, }); const res = await POST(req); expect(res.status).toBe(201); const body = await res.json(); const interestId: string = body.data.id; const [interest] = await db.select().from(interests).where(eq(interests.id, interestId)); expect(interest).toBeDefined(); expect(interest!.yachtId).not.toBeNull(); // Yacht owned by the company const [yacht] = await db.select().from(yachts).where(eq(yachts.id, interest!.yachtId!)); expect(yacht!.currentOwnerType).toBe('company'); // Company exists and matches const [company] = await db .select() .from(companies) .where(eq(companies.id, yacht!.currentOwnerId)); expect(company!.name).toBe(companyName); expect(company!.portId).toBe(port.id); // Ownership-history points at the company const historyRows = await db .select() .from(yachtOwnershipHistory) .where(eq(yachtOwnershipHistory.yachtId, yacht!.id)); expect(historyRows.length).toBe(1); expect(historyRows[0]!.ownerType).toBe('company'); expect(historyRows[0]!.ownerId).toBe(company!.id); // Active membership linking client -> company const memberships = await db .select() .from(companyMemberships) .where( and( eq(companyMemberships.companyId, company!.id), eq(companyMemberships.clientId, interest!.clientId), isNull(companyMemberships.endDate), ), ); expect(memberships.length).toBe(1); expect(memberships[0]!.role).toBe('director'); }); it('reuses existing client when email matches (same port)', async () => { const port = await makePort(); const email = `trio-reuse-${Math.random().toString(36).slice(2, 8)}@test.local`; const firstReq = makeMockRequest( 'POST', `http://localhost/api/public/interests?portId=${port.id}`, { headers: { 'x-forwarded-for': uniqueIp() }, body: { firstName: 'Carol', lastName: 'Returning', email, phone: '+10000000003', yacht: { name: 'First Boat' }, }, }, ); const firstRes = await POST(firstReq); expect(firstRes.status).toBe(201); const firstBody = await firstRes.json(); const [firstInterest] = await db .select() .from(interests) .where(eq(interests.id, firstBody.data.id)); const originalClientId = firstInterest!.clientId; // Second submission with the same email const secondReq = makeMockRequest( 'POST', `http://localhost/api/public/interests?portId=${port.id}`, { headers: { 'x-forwarded-for': uniqueIp() }, body: { firstName: 'Carol', lastName: 'Returning', email, phone: '+10000000003', yacht: { name: 'Second Boat' }, }, }, ); const secondRes = await POST(secondReq); expect(secondRes.status).toBe(201); const secondBody = await secondRes.json(); const [secondInterest] = await db .select() .from(interests) .where(eq(interests.id, secondBody.data.id)); expect(secondInterest!.clientId).toBe(originalClientId); // A second yacht row was created (not deduped) — each submission is its // own inquiry about a possibly-different yacht. const clientsMatching = await db.select().from(clients).where(eq(clients.id, originalClientId)); expect(clientsMatching.length).toBe(1); const [secondYacht] = await db .select() .from(yachts) .where(eq(yachts.id, secondInterest!.yachtId!)); expect(secondYacht!.name).toBe('Second Boat'); expect(secondYacht!.id).not.toBe(firstInterest!.yachtId); }); it('reuses existing company when name matches case-insensitively (same port)', async () => { const port = await makePort(); const email1 = `trio-coreuse1-${Math.random().toString(36).slice(2, 8)}@test.local`; const email2 = `trio-coreuse2-${Math.random().toString(36).slice(2, 8)}@test.local`; const companyName = `Harbor Partners ${Math.random().toString(36).slice(2, 8)}`; const firstReq = makeMockRequest( 'POST', `http://localhost/api/public/interests?portId=${port.id}`, { headers: { 'x-forwarded-for': uniqueIp() }, body: { firstName: 'Dana', lastName: 'Founder', email: email1, phone: '+10000000004', yacht: { name: 'Flagship' }, company: { name: companyName, role: 'director' }, }, }, ); const firstRes = await POST(firstReq); expect(firstRes.status).toBe(201); const firstBody = await firstRes.json(); const [firstInterest] = await db .select() .from(interests) .where(eq(interests.id, firstBody.data.id)); const [firstYacht] = await db .select() .from(yachts) .where(eq(yachts.id, firstInterest!.yachtId!)); const originalCompanyId = firstYacht!.currentOwnerId; // Second submission — same company name, different casing, different client const secondReq = makeMockRequest( 'POST', `http://localhost/api/public/interests?portId=${port.id}`, { headers: { 'x-forwarded-for': uniqueIp() }, body: { firstName: 'Evan', lastName: 'Employee', email: email2, phone: '+10000000005', yacht: { name: 'Second Flagship' }, company: { name: companyName.toUpperCase(), role: 'employee' }, }, }, ); const secondRes = await POST(secondReq); expect(secondRes.status).toBe(201); const secondBody = await secondRes.json(); const [secondInterest] = await db .select() .from(interests) .where(eq(interests.id, secondBody.data.id)); const [secondYacht] = await db .select() .from(yachts) .where(eq(yachts.id, secondInterest!.yachtId!)); expect(secondYacht!.currentOwnerId).toBe(originalCompanyId); // Only one company row exists for that (portId, lowered name) const allCompanies = await db.select().from(companies).where(eq(companies.portId, port.id)); const matching = allCompanies.filter((c) => c.name.toLowerCase() === companyName.toLowerCase()); expect(matching.length).toBe(1); // Second client has its own membership in the same company const memberships = await db .select() .from(companyMemberships) .where( and( eq(companyMemberships.companyId, originalCompanyId), eq(companyMemberships.clientId, secondInterest!.clientId), isNull(companyMemberships.endDate), ), ); expect(memberships.length).toBe(1); expect(memberships[0]!.role).toBe('employee'); }); });