import { describe, it, expect } from 'vitest'; import { eq } from 'drizzle-orm'; import { listHandler, createHandler } from '@/app/api/v1/companies/[id]/members/route'; import { patchHandler, deleteHandler } from '@/app/api/v1/companies/[id]/members/[mid]/route'; import { setPrimaryHandler } from '@/app/api/v1/companies/[id]/members/[mid]/set-primary/route'; import { db } from '@/lib/db'; import { companyMemberships } from '@/lib/db/schema'; import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; import { makeClient, makeCompany, makeFullPermissions, makePort } from '../../helpers/factories'; describe('GET /api/v1/companies/[id]/members', () => { it('returns active memberships for the company', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client1 = await makeClient({ portId: port.id }); const client2 = await makeClient({ portId: port.id }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client1.id, role: 'director', startDate: new Date().toISOString(), }, }), ctx, { id: company.id }, ); await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client2.id, role: 'officer', startDate: new Date().toISOString(), }, }), ctx, { id: company.id }, ); const req = makeMockRequest('GET', `http://localhost/api/v1/companies/${company.id}/members`); const res = await listHandler(req, ctx, { id: company.id }); expect(res.status).toBe(200); const body = await res.json(); expect(Array.isArray(body.data)).toBe(true); expect(body.data.length).toBe(2); expect(body.data.every((m: { endDate: string | null }) => m.endDate === null)).toBe(true); }); it('includes ended when activeOnly=false', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client1 = await makeClient({ portId: port.id }); const client2 = await makeClient({ portId: port.id }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); // Active membership. await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client1.id, role: 'director', startDate: new Date().toISOString(), }, }), ctx, { id: company.id }, ); // Create then end another membership. const createRes = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client2.id, role: 'officer', startDate: new Date().toISOString(), }, }), ctx, { id: company.id }, ); const createdBody = await createRes.json(); const toEndId = createdBody.data.id as string; const delRes = await deleteHandler( makeMockRequest( 'DELETE', `http://localhost/api/v1/companies/${company.id}/members/${toEndId}`, ), ctx, { id: company.id, mid: toEndId }, ); expect(delRes.status).toBe(204); // Default — active only. const activeOnlyRes = await listHandler( makeMockRequest('GET', `http://localhost/api/v1/companies/${company.id}/members`), ctx, { id: company.id }, ); const activeBody = await activeOnlyRes.json(); expect(activeBody.data.length).toBe(1); // activeOnly=false. const allRes = await listHandler( makeMockRequest( 'GET', `http://localhost/api/v1/companies/${company.id}/members?activeOnly=false`, ), ctx, { id: company.id }, ); const allBody = await allRes.json(); expect(allBody.data.length).toBe(2); }); it('returns 404 when company does not exist or cross-tenant', async () => { const portA = await makePort(); const portB = await makePort(); const company = await makeCompany({ portId: portA.id }); const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const req = makeMockRequest('GET', `http://localhost/api/v1/companies/${company.id}/members`); const res = await listHandler(req, ctx, { id: company.id }); expect(res.status).toBe(404); }); }); describe('POST /api/v1/companies/[id]/members', () => { it('adds a membership (201)', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client = await makeClient({ portId: port.id }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client.id, role: 'director', startDate: new Date().toISOString(), isPrimary: true, }, }); const res = await createHandler(req, ctx, { id: company.id }); expect(res.status).toBe(201); const body = await res.json(); expect(body.data.companyId).toBe(company.id); expect(body.data.clientId).toBe(client.id); expect(body.data.role).toBe('director'); expect(body.data.isPrimary).toBe(true); }); it('returns 400 when clientId not found or cross-tenant', async () => { const portA = await makePort(); const portB = await makePort(); const company = await makeCompany({ portId: portA.id }); const clientOtherPort = await makeClient({ portId: portB.id }); const ctx = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() }); const req = makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: clientOtherPort.id, role: 'director', startDate: new Date().toISOString(), }, }); const res = await createHandler(req, ctx, { id: company.id }); expect(res.status).toBe(400); }); it('returns 409 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 ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const startDate = new Date().toISOString(); const body = { clientId: client.id, role: 'director' as const, startDate, }; const first = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body, }), ctx, { id: company.id }, ); expect(first.status).toBe(201); const dupe = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body, }), ctx, { id: company.id }, ); expect(dupe.status).toBe(409); }); }); describe('PATCH /api/v1/companies/[id]/members/[mid]', () => { it('updates membership fields', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client = await makeClient({ portId: port.id }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const createRes = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client.id, role: 'director', startDate: new Date().toISOString(), }, }), ctx, { id: company.id }, ); const created = (await createRes.json()).data; const patchRes = await patchHandler( makeMockRequest( 'PATCH', `http://localhost/api/v1/companies/${company.id}/members/${created.id}`, { body: { role: 'officer', notes: 'promoted', }, }, ), ctx, { id: company.id, mid: created.id }, ); expect(patchRes.status).toBe(200); const body = await patchRes.json(); expect(body.data.role).toBe('officer'); expect(body.data.notes).toBe('promoted'); }); it('returns 404 for cross-tenant membership', async () => { const portA = await makePort(); const portB = await makePort(); const company = await makeCompany({ portId: portA.id }); const client = await makeClient({ portId: portA.id }); const ctxA = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() }); const createRes = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client.id, role: 'director', startDate: new Date().toISOString(), }, }), ctxA, { id: company.id }, ); const created = (await createRes.json()).data; const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const patchRes = await patchHandler( makeMockRequest( 'PATCH', `http://localhost/api/v1/companies/${company.id}/members/${created.id}`, { body: { role: 'officer' } }, ), ctxB, { id: company.id, mid: created.id }, ); expect(patchRes.status).toBe(404); }); }); describe('DELETE /api/v1/companies/[id]/members/[mid]', () => { it('sets endDate to now when no body provided (204)', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client = await makeClient({ portId: port.id }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const createRes = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client.id, role: 'director', startDate: new Date().toISOString(), }, }), ctx, { id: company.id }, ); const created = (await createRes.json()).data; const before = new Date(); const delRes = await deleteHandler( makeMockRequest( 'DELETE', `http://localhost/api/v1/companies/${company.id}/members/${created.id}`, ), ctx, { id: company.id, mid: created.id }, ); expect(delRes.status).toBe(204); const [row] = await db .select() .from(companyMemberships) .where(eq(companyMemberships.id, created.id)); expect(row?.endDate).not.toBeNull(); expect(row!.endDate!.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000); }); it('sets endDate from body when provided', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client = await makeClient({ portId: port.id }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const createRes = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client.id, role: 'director', startDate: new Date('2025-01-01').toISOString(), }, }), ctx, { id: company.id }, ); const created = (await createRes.json()).data; const explicitEnd = new Date('2026-06-01T00:00:00.000Z'); const delRes = await deleteHandler( makeMockRequest( 'DELETE', `http://localhost/api/v1/companies/${company.id}/members/${created.id}`, { body: { endDate: explicitEnd.toISOString() } }, ), ctx, { id: company.id, mid: created.id }, ); expect(delRes.status).toBe(204); const [row] = await db .select() .from(companyMemberships) .where(eq(companyMemberships.id, created.id)); expect(row?.endDate?.toISOString()).toBe(explicitEnd.toISOString()); }); }); describe('POST /api/v1/companies/[id]/members/[mid]/set-primary', () => { it('sets only one membership as primary per company', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id }); const client1 = await makeClient({ portId: port.id }); const client2 = await makeClient({ portId: port.id }); const client3 = await makeClient({ portId: port.id }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); // M1: primary from the start. const m1Res = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client1.id, role: 'director', startDate: new Date().toISOString(), isPrimary: true, }, }), ctx, { id: company.id }, ); const m1 = (await m1Res.json()).data; // M2, M3 — not primary. const m2Res = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client2.id, role: 'officer', startDate: new Date().toISOString(), }, }), ctx, { id: company.id }, ); const m2 = (await m2Res.json()).data; const m3Res = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { body: { clientId: client3.id, role: 'employee', startDate: new Date().toISOString(), }, }), ctx, { id: company.id }, ); const m3 = (await m3Res.json()).data; // Promote M2. const setPrimRes = await setPrimaryHandler( makeMockRequest( 'POST', `http://localhost/api/v1/companies/${company.id}/members/${m2.id}/set-primary`, ), ctx, { id: company.id, mid: m2.id }, ); expect(setPrimRes.status).toBe(200); const setPrimBody = await setPrimRes.json(); expect(setPrimBody.data.id).toBe(m2.id); expect(setPrimBody.data.isPrimary).toBe(true); // Verify DB state: only M2 is primary. const rows = await db .select() .from(companyMemberships) .where(eq(companyMemberships.companyId, company.id)); const primary = rows.filter((r) => r.isPrimary); expect(primary.length).toBe(1); expect(primary[0]!.id).toBe(m2.id); const m1Row = rows.find((r) => r.id === m1.id); expect(m1Row?.isPrimary).toBe(false); const m3Row = rows.find((r) => r.id === m3.id); expect(m3Row?.isPrimary).toBe(false); }); });