308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
|
|
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 persists across requests inside a
|
||
|
|
// single process. Use a unique IP per test call to avoid 429s.
|
||
|
|
let ipCounter = 1;
|
||
|
|
function uniqueIp(): string {
|
||
|
|
ipCounter += 1;
|
||
|
|
return `10.0.${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');
|
||
|
|
});
|
||
|
|
});
|