Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .claude/skills/agenza-api-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,11 @@ removed the harness) — the next one sets the precedent. The illustrative shape
- Add a `toDomain(dto)` mapper only when wire and domain genuinely diverge; then transform the `ok`
branch and forward the failure object unchanged.

> Numeric fields generate as `number | string` (the backend accepts numbers from strings). If your
> entity declares `price: number`, verbatim forwarding **will not compile** and you need a
> `toDomain`. That is the mapper's most common real trigger, not a style choice.
> A numeric field generates as `number`, although the OpenAPI document says `integer | string`: ASP.NET
> also reads numbers from text but only ever writes a number, and `numberAcceptedAsText` in
> `scripts/generateApiTypes.mjs` narrows it. An entity that declares `price: number` therefore forwards
> verbatim. A `toDomain` is needed only when wire and domain genuinely diverge (a renamed field, a
> different structure), not for a number.

## Where a `Result` becomes a rejection

Expand Down
59 changes: 32 additions & 27 deletions apps/admin-frontend/docs/ARCHITECTURE.md

Large diffs are not rendered by default.

99 changes: 99 additions & 0 deletions apps/admin-frontend/e2e/clients.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,102 @@ test.describe('Register a person', () => {
await expect(page).toHaveURL(/\/pessoas\/nova$/);
});
});

function formatCpf(digits: string): string {
return `${digits.slice(0, 3)}.${digits.slice(3, 6)}.${digits.slice(6, 9)}-${digits.slice(9)}`;
}

async function registerPerson(
page: Page,
person: { name: string; email: string; cpf: string },
): Promise<void> {
await openNewPersonForm(page);
const section = page.getByRole('region', { name: 'Dados da pessoa' });
await section.getByLabel('Nome completo').fill(person.name);
await section.getByLabel('E-mail').fill(person.email);
await section.getByLabel('CPF').fill(person.cpf);
await page.getByRole('button', { name: 'Salvar' }).click();
await expect(page).toHaveURL(/\/pessoas$/);
}

async function searchFor(page: Page, term: string): Promise<void> {
const search = page.getByLabel('Buscar pessoas por nome, e-mail ou CPF');
await search.fill(term);
await search.press('Enter');
}

test.describe('People list', () => {
test.beforeEach(async ({ page }) => {
await loginAsDemoUser(page);
});

test('finds a registered person by name, e-mail and masked CPF, showing the whole CPF and the situation', async ({
page,
}) => {
const person = {
name: uniqueName('Lista E2E'),
email: `lista.${Date.now()}@example.com`,
cpf: validCpf(),
};
await registerPerson(page, person);

for (const term of [person.name, person.email, formatCpf(person.cpf)]) {
await searchFor(page, term);
const row = page.getByRole('row', { name: new RegExp(person.name) });
await expect(row).toBeVisible();
await expect(row).toContainText(person.email);
await expect(row).toContainText(formatCpf(person.cpf));
await expect(row).toContainText('Ativa');
}
});

test('keeps the search in the URL, so it survives a reload', async ({ page }) => {
const person = {
name: uniqueName('Recarga E2E'),
email: `recarga.${Date.now()}@example.com`,
cpf: validCpf(),
};
await registerPerson(page, person);

await searchFor(page, person.name);
await expect(page).toHaveURL(/\/pessoas\?q=/);
await page.reload();

await expect(page.getByLabel('Buscar pessoas por nome, e-mail ou CPF')).toHaveValue(
person.name,
);
await expect(page.getByRole('row', { name: new RegExp(person.name) })).toBeVisible();
});

test('names a search that finds nobody and clears it', async ({ page }) => {
await page.getByRole('link', { name: 'Pessoas' }).click();
await expect(page).toHaveURL(/\/pessoas$/);
const term = `inexistente-${Date.now()}`;

await searchFor(page, term);

await expect(page.getByText(`Nenhuma pessoa encontrada para «${term}»`)).toBeVisible();
await page.getByRole('button', { name: 'Limpar busca' }).click();
await expect(page).toHaveURL(/\/pessoas$/);
await expect(page.getByLabel('Buscar pessoas por nome, e-mail ou CPF')).toHaveValue('');
await expect(page.getByText(/Nenhuma pessoa encontrada para/)).toHaveCount(0);
});

test('opens the person details from the name, which still says it is coming', async ({
page,
}) => {
const person = {
name: uniqueName('Detalhe E2E'),
email: `detalhe.${Date.now()}@example.com`,
cpf: validCpf(),
};
await registerPerson(page, person);
await searchFor(page, person.name);

await page.getByRole('link', { name: person.name }).click();

await expect(page).toHaveURL(/\/pessoas\/[0-9a-f-]{36}$/);
await expect(page.getByRole('heading', { name: 'Detalhes da pessoa' })).toBeVisible();
await expect(page.getByText('Em breve', { exact: true })).toBeVisible();
});
});
31 changes: 30 additions & 1 deletion apps/admin-frontend/scripts/generateApiTypes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,39 @@ function requestDateAsDate(requestOnly) {
};
}

// ASP.NET publishes every number as `integer | string` (or `number | string`) because it also reads
// numbers from text, but it only ever writes a JSON number, so the TypeScript type is `number`.
function numberAcceptedAsText(schemaObject) {
const types = schemaObject.type;
if (!Array.isArray(types) || !types.includes('string')) return undefined;
if (!types.includes('integer') && !types.includes('number')) return undefined;

const number = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
if (!types.includes('null')) return number;

return ts.factory.createUnionTypeNode([
ts.factory.createLiteralTypeNode(ts.factory.createNull()),
number,
]);
}

function firstTransformThatApplies(...transforms) {
return (schemaObject, options) => {
for (const transform of transforms) {
const node = transform(schemaObject, options);
if (node !== undefined) return node;
}
return undefined;
};
}

export async function typesFromDocument(openApiDocument) {
const document = stripTenantHeaderParam(openApiDocument);
const ast = await openapiTS(document, {
transform: requestDateAsDate(requestBodyOnlySchemas(document)),
transform: firstTransformThatApplies(
requestDateAsDate(requestBodyOnlySchemas(document)),
numberAcceptedAsText,
),
});
return astToString(ast);
}
Expand Down
40 changes: 38 additions & 2 deletions apps/admin-frontend/scripts/generateApiTypes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function openApiDocument() {
get: {
parameters: [
{ name: 'filter', in: 'query', schema: { $ref: '#/components/schemas/AgendaFilter' } },
{ name: 'Page', in: 'query', schema: { type: ['integer', 'string'], format: 'int32' } },
],
responses: ok('PersonResponse'),
},
Expand All @@ -51,8 +52,15 @@ function openApiDocument() {
},
PersonResponse: {
type: 'object',
required: ['birthDate'],
properties: { birthDate: { type: ['null', 'string'], format: 'date' } },
required: ['birthDate', 'visits', 'balance', 'rank', 'age', 'nickname'],
properties: {
birthDate: { type: ['null', 'string'], format: 'date' },
visits: { type: ['integer', 'string'], format: 'int32', pattern: '^-?(?:0|[1-9]\d*)$' },
balance: { type: ['number', 'string'], format: 'double' },
rank: { type: ['null', 'integer', 'string'], format: 'int32' },
age: { type: 'integer', format: 'int32' },
nickname: { type: ['null', 'string'] },
},
},
EventBody: {
type: 'object',
Expand Down Expand Up @@ -108,4 +116,32 @@ describe('typesFromDocument', () => {

expect(typeOf(types, 'CreatePersonRequest', 'createdAt')).toBe('string');
});

it.each([
['an integer', 'visits', 'number'],
['a decimal', 'balance', 'number'],
['a nullable integer', 'rank', 'null | number'],
])(
'types %s that ASP.NET also reads from text as a plain number',
async (_, property, expected) => {
const types = await typesFromDocument(openApiDocument());

expect(typeOf(types, 'PersonResponse', property)).toBe(expected);
},
);

it('types a numeric query parameter as a plain number', async () => {
const types = await typesFromDocument(openApiDocument());

expect(types).toMatch(/\bPage\?: number;/);
});

it.each([
['a plain integer', 'age', 'number'],
['a nullable string', 'nickname', 'null | string'],
])('leaves %s as it was', async (_, property, expected) => {
const types = await typesFromDocument(openApiDocument());

expect(typeOf(types, 'PersonResponse', property)).toBe(expected);
});
});
17 changes: 17 additions & 0 deletions apps/admin-frontend/src/app/pages/ClientDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { ClientDetails } from './ClientDetails';

describe('ClientDetails', () => {
it('explains that the person details are coming, instead of presenting a working screen', () => {
render(
<MemoryRouter>
<ClientDetails />
</MemoryRouter>,
);

expect(screen.getByRole('heading', { name: 'Detalhes da pessoa' })).toBeInTheDocument();
expect(screen.getByText('Em breve')).toBeInTheDocument();
});
});
12 changes: 12 additions & 0 deletions apps/admin-frontend/src/app/pages/ClientDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Users } from 'lucide-react';
import { ComingSoon } from './ComingSoon';

export function ClientDetails() {
return (
<ComingSoon
icon={Users}
title="Detalhes da pessoa"
description="Os dados, os responsáveis e as pessoas de referência de cada pessoa atendida vão aparecer aqui."
/>
);
}
20 changes: 0 additions & 20 deletions apps/admin-frontend/src/app/pages/Clients.test.tsx

This file was deleted.

22 changes: 0 additions & 22 deletions apps/admin-frontend/src/app/pages/Clients.tsx

This file was deleted.

8 changes: 7 additions & 1 deletion apps/admin-frontend/src/app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,19 @@ export const router = createBrowserRouter([
children: [
{
index: true,
lazy: () => import('./pages/Clients').then((m) => ({ Component: m.Clients })),
lazy: () =>
import('@/features/clients').then((m) => ({ Component: m.ClientListPage })),
},
{
path: 'nova',
lazy: () =>
import('@/features/clients').then((m) => ({ Component: m.ClientFormPage })),
},
{
path: ':id',
lazy: () =>
import('./pages/ClientDetails').then((m) => ({ Component: m.ClientDetails })),
},
],
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,78 @@ import { utcDate } from '@/shared/format/date';
import type { ClientInput } from '../model/client';
import { clientsRepository } from './clientsRepository';

const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() }));
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));

vi.mock('@/shared/api/servicesApi', () => ({
servicesApi: { post: mockPost },
servicesApi: { get: mockGet, post: mockPost },
}));

const LIST_ITEM = {
id: 'abc',
fullName: 'Maria Souza',
birthDate: null,
phone: null,
email: 'maria@example.com',
cpf: '52998224725',
status: 'active',
};

describe('clientsRepository.list', () => {
it('sends the search term as Search and nothing else, leaving the backend defaults (everyone, first page) in charge', async () => {
mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } });

await clientsRepository.list('maria');

expect(mockGet).toHaveBeenCalledWith('/api/v{version}/clients', {
query: { Search: 'maria' },
signal: undefined,
});
});

it('sends no query at all without a search term', async () => {
mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } });

await clientsRepository.list();

expect(mockGet).toHaveBeenCalledWith('/api/v{version}/clients', {
query: undefined,
signal: undefined,
});
});

it('hands the abort signal over to the request', async () => {
mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } });
const controller = new AbortController();

await clientsRepository.list('maria', controller.signal);

const [, options] = mockGet.mock.calls.at(-1) as [string, { signal: AbortSignal }];
expect(options.signal).toBe(controller.signal);
});

it('forwards the whole page verbatim, with the items and the paging data', async () => {
const page = { items: [LIST_ITEM], totalCount: 21, page: 1, pageSize: 20 };
mockGet.mockResolvedValue({ ok: true, data: page });

const result = await clientsRepository.list();

expect(result).toEqual({ ok: true, data: page });
});

it('forwards a failed result verbatim, without throwing (e.g. a search past the limit)', async () => {
const error = {
status: 400,
code: 'Validation.Failed',
title: 'A busca deve ter no máximo 254 caracteres.',
};
mockGet.mockResolvedValue({ ok: false, error });

const result = await clientsRepository.list('x');

expect(result).toEqual({ ok: false, error });
});
});

const INPUT: ClientInput = {
fullName: 'Maria Souza',
birthDate: utcDate(2015, 3, 10),
Expand Down
Loading
Loading