Files
pn-new-crm/tests/integration/inquiries.service.test.ts

208 lines
6.9 KiB
TypeScript
Raw Normal View History

/**
* Inquiries workbench service: list/filter/triage/get + convert-to-client and
* convert-to-interest (find-or-create client, tracking columns, port isolation).
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { and, eq } from 'drizzle-orm';
vi.mock('@/lib/socket/server', () => ({
emitToRoom: vi.fn(),
}));
import { db } from '@/lib/db';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { user } from '@/lib/db/schema/users';
import {
listInquiries,
getInquiryById,
triageInquiry,
convertInquiryToClient,
convertInquiryToInterest,
} from '@/lib/services/inquiries.service';
import { makePort } from '../helpers/factories';
import type { AuditMeta } from '@/lib/audit';
let META: AuditMeta;
beforeAll(async () => {
const [u] = await db.select({ id: user.id }).from(user).limit(1);
if (!u) throw new Error('No user available; run pnpm db:seed first');
META = { userId: u.id, portId: '', ipAddress: '127.0.0.1', userAgent: 'test' };
});
function metaFor(portId: string): AuditMeta {
return { ...META, portId };
}
async function seedInquiry(
portId: string,
opts: {
kind?: 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
triageState?: string;
contactName?: string | null;
contactEmail?: string | null;
payload?: Record<string, unknown>;
} = {},
) {
const [row] = await db
.insert(websiteSubmissions)
.values({
portId,
submissionId: crypto.randomUUID(),
kind: opts.kind ?? 'contact_form',
payload: opts.payload ?? {},
contactName: opts.contactName ?? 'Jane Doe',
contactEmail: opts.contactEmail ?? 'jane@example.com',
triageState: opts.triageState ?? 'open',
})
.returning();
return row!;
}
describe('inquiries.service — list / get / triage', () => {
it('filters by kind and state, searches name/email, scoped to port', async () => {
const port = await makePort();
const other = await makePort();
await seedInquiry(port.id, {
kind: 'contact_form',
contactName: 'Alice Smith',
contactEmail: 'alice@x.com',
});
await seedInquiry(port.id, {
kind: 'berth_inquiry',
contactName: 'Bob Jones',
contactEmail: 'bob@x.com',
});
await seedInquiry(port.id, {
kind: 'contact_form',
triageState: 'dismissed',
contactName: 'Carol',
});
await seedInquiry(other.id, {
kind: 'contact_form',
contactName: 'Alice Smith',
contactEmail: 'alice@x.com',
});
const base = { page: 1, limit: 25, order: 'desc' as const, includeArchived: false };
// inbox (open+assigned) excludes the dismissed one
const inbox = await listInquiries(port.id, { ...base, state: 'inbox' });
expect(inbox.total).toBe(2);
// kind filter
const contacts = await listInquiries(port.id, { ...base, state: 'all', kind: 'contact_form' });
expect(contacts.total).toBe(2);
// search by name
const alice = await listInquiries(port.id, { ...base, state: 'all', search: 'alice' });
expect(alice.total).toBe(1);
expect(alice.data[0]!.contactName).toBe('Alice Smith');
// port isolation
expect(alice.data.every((r) => r.portId === port.id)).toBe(true);
});
it('triageInquiry updates state + triagedBy; getInquiryById returns the row', async () => {
const port = await makePort();
const row = await seedInquiry(port.id);
const updated = await triageInquiry(row.id, port.id, 'assigned', metaFor(port.id));
expect(updated.triageState).toBe('assigned');
expect(updated.triagedBy).toBe(META.userId);
const fetched = await getInquiryById(row.id, port.id);
expect(fetched.id).toBe(row.id);
expect(fetched.convertedClient).toBeNull();
});
});
describe('inquiries.service — convert', () => {
it('convert to client creates a new client when no email match', async () => {
const port = await makePort();
const row = await seedInquiry(port.id, {
contactName: 'New Lead',
contactEmail: 'newlead@example.com',
payload: {
first_name: 'New',
last_name: 'Lead',
email: 'newlead@example.com',
phone: '+15551234567',
},
});
const res = await convertInquiryToClient(row.id, port.id, metaFor(port.id));
expect(res.clientId).toBeTruthy();
expect(res.interestId).toBeNull();
const [c] = await db.select().from(clients).where(eq(clients.id, res.clientId)).limit(1);
expect(c!.fullName).toBe('New Lead');
expect(c!.source).toBe('website');
const sub = await getInquiryById(row.id, port.id);
expect(sub.triageState).toBe('converted');
expect(sub.convertedClientId).toBe(res.clientId);
});
it('convert links an existing client on a unique email match (no duplicate)', async () => {
const port = await makePort();
// Pre-existing client with the same email.
const [existing] = await db
.insert(clients)
.values({ portId: port.id, fullName: 'Existing Client', source: 'manual' })
.returning();
await db
.insert(clientContacts)
.values({
clientId: existing!.id,
channel: 'email',
value: 'dup@example.com',
isPrimary: true,
});
const row = await seedInquiry(port.id, {
contactEmail: 'dup@example.com',
payload: { email: 'dup@example.com' },
});
const res = await convertInquiryToClient(row.id, port.id, metaFor(port.id));
expect(res.clientId).toBe(existing!.id);
const all = await db.select().from(clients).where(eq(clients.portId, port.id));
expect(all).toHaveLength(1); // no duplicate created
});
it('convert to interest find-or-creates the client and creates an interest', async () => {
const port = await makePort();
const row = await seedInquiry(port.id, {
contactName: 'Deal Maker',
contactEmail: 'deal@example.com',
payload: { first_name: 'Deal', last_name: 'Maker', email: 'deal@example.com' },
});
const res = await convertInquiryToInterest(row.id, port.id, metaFor(port.id));
expect(res.clientId).toBeTruthy();
expect(res.interestId).toBeTruthy();
const [i] = await db.select().from(interests).where(eq(interests.id, res.interestId!)).limit(1);
expect(i!.clientId).toBe(res.clientId);
expect(i!.pipelineStage).toBe('enquiry');
const sub = await getInquiryById(row.id, port.id);
expect(sub.triageState).toBe('converted');
expect(sub.convertedInterestId).toBe(res.interestId);
expect(sub.convertedClientId).toBe(res.clientId);
});
it('convert to interest twice is rejected', async () => {
const port = await makePort();
const row = await seedInquiry(port.id, {
contactEmail: 'once@example.com',
payload: { email: 'once@example.com' },
});
await convertInquiryToInterest(row.id, port.id, metaFor(port.id));
await expect(convertInquiryToInterest(row.id, port.id, metaFor(port.id))).rejects.toThrow();
});
});