feat(addresses): full CRUD UI for client + company multi-address
Client and company detail pages each gain an Addresses tab with click-to-edit fields wired to the existing CountryCombobox/SubdivisionCombobox primitives. Adds a primary toggle that demotes the previous primary inside one transaction so the partial unique index never trips. - New service helpers: list/add/update/remove ClientAddress + CompanyAddress - New routes: /api/v1/clients/[id]/addresses[/addressId], same under companies/ - New shared component: <AddressesEditor> reused by both detail surfaces - Integration tests cover happy path, primary demotion, and tenant scoping Tests: 747/747 vitest (was 741, +6 address tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
194
tests/integration/addresses.test.ts
Normal file
194
tests/integration/addresses.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clientAddresses, companyAddresses } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
listClientAddresses,
|
||||
addClientAddress,
|
||||
updateClientAddress,
|
||||
removeClientAddress,
|
||||
} from '@/lib/services/clients.service';
|
||||
import {
|
||||
listCompanyAddresses,
|
||||
addCompanyAddress,
|
||||
updateCompanyAddress,
|
||||
removeCompanyAddress,
|
||||
} from '@/lib/services/companies.service';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
import { makePort, makeClient, makeCompany } from '../helpers/factories';
|
||||
|
||||
const META = (portId: string) => ({
|
||||
userId: 'test-user',
|
||||
portId,
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'vitest',
|
||||
});
|
||||
|
||||
describe('client addresses service', () => {
|
||||
it('adds, lists, updates, and removes a client address', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
|
||||
// Initially empty.
|
||||
const empty = await listClientAddresses(client.id, port.id);
|
||||
expect(empty).toHaveLength(0);
|
||||
|
||||
const added = await addClientAddress(
|
||||
client.id,
|
||||
port.id,
|
||||
{
|
||||
label: 'Home',
|
||||
streetAddress: '1 Pier Rd',
|
||||
city: 'Marbella',
|
||||
countryIso: 'ES',
|
||||
subdivisionIso: 'ES-MA',
|
||||
postalCode: '29602',
|
||||
isPrimary: true,
|
||||
},
|
||||
META(port.id),
|
||||
);
|
||||
|
||||
expect(added.label).toBe('Home');
|
||||
expect(added.countryIso).toBe('ES');
|
||||
expect(added.isPrimary).toBe(true);
|
||||
|
||||
const list = await listClientAddresses(client.id, port.id);
|
||||
expect(list).toHaveLength(1);
|
||||
|
||||
const updated = await updateClientAddress(
|
||||
added.id,
|
||||
client.id,
|
||||
port.id,
|
||||
{ city: 'Málaga' },
|
||||
META(port.id),
|
||||
);
|
||||
expect(updated.city).toBe('Málaga');
|
||||
|
||||
await removeClientAddress(added.id, client.id, port.id, META(port.id));
|
||||
|
||||
const after = await listClientAddresses(client.id, port.id);
|
||||
expect(after).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('demotes an existing primary when adding a new primary', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
|
||||
const first = await addClientAddress(
|
||||
client.id,
|
||||
port.id,
|
||||
{ label: 'Home', isPrimary: true },
|
||||
META(port.id),
|
||||
);
|
||||
|
||||
const second = await addClientAddress(
|
||||
client.id,
|
||||
port.id,
|
||||
{ label: 'Office', isPrimary: true },
|
||||
META(port.id),
|
||||
);
|
||||
|
||||
const rows = await db.query.clientAddresses.findMany({
|
||||
where: eq(clientAddresses.clientId, client.id),
|
||||
});
|
||||
const primaries = rows.filter((r) => r.isPrimary);
|
||||
expect(primaries).toHaveLength(1);
|
||||
expect(primaries[0]!.id).toBe(second.id);
|
||||
|
||||
// The previously-primary row is now demoted, not deleted.
|
||||
const firstAfter = rows.find((r) => r.id === first.id);
|
||||
expect(firstAfter?.isPrimary).toBe(false);
|
||||
});
|
||||
|
||||
it('demotes other primaries when patching to primary=true', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
|
||||
const first = await addClientAddress(
|
||||
client.id,
|
||||
port.id,
|
||||
{ label: 'Home', isPrimary: true },
|
||||
META(port.id),
|
||||
);
|
||||
const second = await addClientAddress(
|
||||
client.id,
|
||||
port.id,
|
||||
{ label: 'Office', isPrimary: false },
|
||||
META(port.id),
|
||||
);
|
||||
|
||||
await updateClientAddress(second.id, client.id, port.id, { isPrimary: true }, META(port.id));
|
||||
|
||||
const rows = await db.query.clientAddresses.findMany({
|
||||
where: eq(clientAddresses.clientId, client.id),
|
||||
});
|
||||
const primary = rows.find((r) => r.isPrimary);
|
||||
expect(primary?.id).toBe(second.id);
|
||||
expect(rows.find((r) => r.id === first.id)?.isPrimary).toBe(false);
|
||||
});
|
||||
|
||||
it('is tenant-scoped (cross-port access throws NotFoundError)', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const client = await makeClient({ portId: portA.id });
|
||||
|
||||
await expect(listClientAddresses(client.id, portB.id)).rejects.toThrow(NotFoundError);
|
||||
await expect(
|
||||
addClientAddress(client.id, portB.id, { label: 'X' }, META(portB.id)),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('company addresses service', () => {
|
||||
it('adds, lists, updates, and removes a company address', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
|
||||
const added = await addCompanyAddress(
|
||||
company.id,
|
||||
port.id,
|
||||
{ label: 'HQ', countryIso: 'GB', isPrimary: true },
|
||||
META(port.id),
|
||||
);
|
||||
expect(added.countryIso).toBe('GB');
|
||||
|
||||
const list = await listCompanyAddresses(company.id, port.id);
|
||||
expect(list).toHaveLength(1);
|
||||
|
||||
const updated = await updateCompanyAddress(
|
||||
added.id,
|
||||
company.id,
|
||||
port.id,
|
||||
{ city: 'London' },
|
||||
META(port.id),
|
||||
);
|
||||
expect(updated.city).toBe('London');
|
||||
|
||||
await removeCompanyAddress(added.id, company.id, port.id, META(port.id));
|
||||
const after = await db.query.companyAddresses.findMany({
|
||||
where: eq(companyAddresses.companyId, company.id),
|
||||
});
|
||||
expect(after).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('demotes an existing primary when adding a new primary', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
|
||||
await addCompanyAddress(company.id, port.id, { label: 'HQ', isPrimary: true }, META(port.id));
|
||||
const second = await addCompanyAddress(
|
||||
company.id,
|
||||
port.id,
|
||||
{ label: 'Branch', isPrimary: true },
|
||||
META(port.id),
|
||||
);
|
||||
|
||||
const rows = await db.query.companyAddresses.findMany({
|
||||
where: eq(companyAddresses.companyId, company.id),
|
||||
});
|
||||
const primaries = rows.filter((r) => r.isPrimary);
|
||||
expect(primaries).toHaveLength(1);
|
||||
expect(primaries[0]!.id).toBe(second.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user