import { describe, it, expect } from 'vitest'; import { addMembership, updateMembership, endMembership, setPrimary, listByCompany, listByClient, } from '@/lib/services/company-memberships.service'; import { makeCompany, makeClient, makePort, makeAuditMeta } from '../../helpers/factories'; import { db } from '@/lib/db'; import { companyMemberships } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; describe('company-memberships.service — addMembership', () => { it('creates a membership for a valid client + company in the same port', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client = await makeClient({ portId: port.id }); const membership = await addMembership( company.id, port.id, { clientId: client.id, role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); expect(membership.id).toBeTruthy(); expect(membership.companyId).toBe(company.id); expect(membership.clientId).toBe(client.id); expect(membership.role).toBe('director'); expect(membership.endDate).toBeNull(); }); it('throws ValidationError when company not found', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); await expect( addMembership( 'nonexistent-company', port.id, { clientId: client.id, role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ), ).rejects.toBeInstanceOf(ValidationError); }); it('throws ValidationError when client not found', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); await expect( addMembership( company.id, port.id, { clientId: 'nonexistent-client', role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ), ).rejects.toBeInstanceOf(ValidationError); }); it('throws ValidationError when client is in a different port (cross-tenant)', async () => { const portA = await makePort(); const portB = await makePort(); const company = await makeCompany({ portId: portA.id }); const clientInB = await makeClient({ portId: portB.id }); await expect( addMembership( company.id, portA.id, { clientId: clientInB.id, role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: portA.id }), ), ).rejects.toBeInstanceOf(ValidationError); }); it('throws ConflictError when exact duplicate (companyId + clientId + role + startDate)', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client = await makeClient({ portId: port.id }); const startDate = new Date('2026-01-01'); await addMembership( company.id, port.id, { clientId: client.id, role: 'director', startDate, isPrimary: false }, makeAuditMeta({ portId: port.id }), ); await expect( addMembership( company.id, port.id, { clientId: client.id, role: 'director', startDate, isPrimary: false }, makeAuditMeta({ portId: port.id }), ), ).rejects.toBeInstanceOf(ConflictError); }); }); describe('company-memberships.service — updateMembership', () => { it('updates fields', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client = await makeClient({ portId: port.id }); const membership = await addMembership( company.id, port.id, { clientId: client.id, role: 'officer', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); const updated = await updateMembership( membership.id, port.id, { role: 'director', notes: 'Promoted' }, makeAuditMeta({ portId: port.id }), ); expect(updated.role).toBe('director'); expect(updated.notes).toBe('Promoted'); }); it('throws NotFoundError for cross-tenant', async () => { const portA = await makePort(); const portB = await makePort(); const company = await makeCompany({ portId: portB.id }); const client = await makeClient({ portId: portB.id }); const membership = await addMembership( company.id, portB.id, { clientId: client.id, role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: portB.id }), ); await expect( updateMembership( membership.id, portA.id, { notes: 'Hijack' }, makeAuditMeta({ portId: portA.id }), ), ).rejects.toBeInstanceOf(NotFoundError); }); }); describe('company-memberships.service — setPrimary', () => { it('sets only one membership as primary per company (atomic un-primary others)', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const clientA = await makeClient({ portId: port.id }); const clientB = await makeClient({ portId: port.id }); const clientC = await makeClient({ portId: port.id }); const m1 = await addMembership( company.id, port.id, { clientId: clientA.id, role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); const m2 = await addMembership( company.id, port.id, { clientId: clientB.id, role: 'officer', startDate: new Date('2026-02-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); const m3 = await addMembership( company.id, port.id, { clientId: clientC.id, role: 'broker', startDate: new Date('2026-03-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); // Mark all primary via the service — only the last call should leave a // single primary survivor (m3). await setPrimary(m1.id, port.id, makeAuditMeta({ portId: port.id })); await setPrimary(m2.id, port.id, makeAuditMeta({ portId: port.id })); await setPrimary(m3.id, port.id, makeAuditMeta({ portId: port.id })); const rows = await db .select() .from(companyMemberships) .where(eq(companyMemberships.companyId, company.id)); const primaryRows = rows.filter((r) => r.isPrimary); expect(primaryRows).toHaveLength(1); expect(primaryRows[0]!.id).toBe(m3.id); }); it('throws NotFoundError for cross-tenant membership', async () => { const portA = await makePort(); const portB = await makePort(); const company = await makeCompany({ portId: portB.id }); const client = await makeClient({ portId: portB.id }); const membership = await addMembership( company.id, portB.id, { clientId: client.id, role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: portB.id }), ); await expect( setPrimary(membership.id, portA.id, makeAuditMeta({ portId: portA.id })), ).rejects.toBeInstanceOf(NotFoundError); }); }); describe('company-memberships.service — endMembership', () => { it('sets endDate', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client = await makeClient({ portId: port.id }); const membership = await addMembership( company.id, port.id, { clientId: client.id, role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); const endDate = new Date('2026-06-30'); const ended = await endMembership( membership.id, port.id, { endDate }, makeAuditMeta({ portId: port.id }), ); expect(ended.endDate).not.toBeNull(); expect(ended.endDate!.getTime()).toBe(endDate.getTime()); }); }); describe('company-memberships.service — listByCompany / listByClient', () => { it('returns active memberships only by default', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const clientA = await makeClient({ portId: port.id }); const clientB = await makeClient({ portId: port.id }); const active = await addMembership( company.id, port.id, { clientId: clientA.id, role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); const endedMembership = await addMembership( company.id, port.id, { clientId: clientB.id, role: 'officer', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); await endMembership( endedMembership.id, port.id, { endDate: new Date('2026-03-01') }, makeAuditMeta({ portId: port.id }), ); const results = await listByCompany(company.id, port.id); const ids = results.map((m) => m.id); expect(ids).toContain(active.id); expect(ids).not.toContain(endedMembership.id); }); it('includes ended memberships when activeOnly=false', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client = await makeClient({ portId: port.id }); const membership = await addMembership( company.id, port.id, { clientId: client.id, role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); await endMembership( membership.id, port.id, { endDate: new Date('2026-02-01') }, makeAuditMeta({ portId: port.id }), ); const resultsActive = await listByCompany(company.id, port.id); expect(resultsActive.map((m) => m.id)).not.toContain(membership.id); const resultsAll = await listByCompany(company.id, port.id, { activeOnly: false }); expect(resultsAll.map((m) => m.id)).toContain(membership.id); }); it('is tenant-scoped (listByCompany throws NotFoundError for cross-tenant company)', async () => { const portA = await makePort(); const portB = await makePort(); const companyInB = await makeCompany({ portId: portB.id }); await expect(listByCompany(companyInB.id, portA.id)).rejects.toBeInstanceOf(NotFoundError); }); it('is tenant-scoped (listByClient throws NotFoundError for cross-tenant client)', async () => { const portA = await makePort(); const portB = await makePort(); const clientInB = await makeClient({ portId: portB.id }); await expect(listByClient(clientInB.id, portA.id)).rejects.toBeInstanceOf(NotFoundError); }); it('listByClient returns active memberships only by default', async () => { const port = await makePort(); const companyA = await makeCompany({ portId: port.id }); const companyB = await makeCompany({ portId: port.id }); const client = await makeClient({ portId: port.id }); const active = await addMembership( companyA.id, port.id, { clientId: client.id, role: 'director', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); const endedMembership = await addMembership( companyB.id, port.id, { clientId: client.id, role: 'officer', startDate: new Date('2026-01-01'), isPrimary: false, }, makeAuditMeta({ portId: port.id }), ); await endMembership( endedMembership.id, port.id, { endDate: new Date('2026-03-01') }, makeAuditMeta({ portId: port.id }), ); const results = await listByClient(client.id, port.id); const ids = results.map((m) => m.id); expect(ids).toContain(active.id); expect(ids).not.toContain(endedMembership.id); }); });