Files
pn-new-crm/tests/integration/api/memberships.test.ts
Matt d3960af340 feat: warm-up deps — ts-reset, web-vitals, RHF devtool, query-broadcast
Four low-risk adds before the Zod 4 / drizzle-zod headliner:

- @total-typescript/ts-reset: tightens TS stdlib types globally (JSON.parse
  → unknown, fetch().json() → unknown, .filter(Boolean) narrows, Set
  literals respect typed Set targets). Caught 179 latent type errors;
  fixed all production sites (8 files) and added `any` cast escape hatch
  in test files (ESLint exemption scoped to tests/).
- web-vitals + /api/v1/internal/vitals endpoint + WebVitalsReporter
  client component: establishes Core Web Vitals baseline (LCP/INP/CLS/
  FCP/TTFB) via navigator.sendBeacon. Required before optimisation work.
- @hookform/devtools + FormDevtool wrapper: dev-only RHF state inspector,
  lazy-loaded via next/dynamic so the chunk is excluded from prod
  bundles entirely.
- @tanstack/query-broadcast-client-experimental: cross-tab cache sync
  via BroadcastChannel — wired in query-provider.tsx, 1-liner.

Audit doc updated with sections 35 + 36 (PDF stack overhaul + comprehensive
second-pass package sweep) covering ~20 package adoption candidates and
4-5 deprecation candidates.

Verified: tsc clean, vitest 1293/1293 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:16:18 +02:00

435 lines
15 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { eq } from 'drizzle-orm';
import { listHandler, createHandler } from '@/app/api/v1/companies/[id]/members/handlers';
import { patchHandler, deleteHandler } from '@/app/api/v1/companies/[id]/members/[mid]/handlers';
import { setPrimaryHandler } from '@/app/api/v1/companies/[id]/members/[mid]/set-primary/handlers';
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()) as any;
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()) as any;
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()) as any;
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()) as any;
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()) as any;
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()) as any).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()) as any;
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()) as any).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()) as any).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()) as any).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()) as any).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()) as any).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()) as any).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()) as any;
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);
});
});