import { describe, it, expect } from 'vitest' import { catalogRowsFor, contractFor, serializeContract, PUBLIC_API_CONTRACT_VERSION, } from '../external-api-catalog.js' /** * The published contract a generated endpoint carries on its catalogue row. * * It exists because the platform's admin surfaces — the external account's AI prompt, the catalogue * detail page, the Postman collection, the OpenAPI spec — describe endpoints they cannot know in * advance. Until the row carried this, those four hardcoded the platform's own five export endpoints * in the browser: a client saw `/api/v1/export/users` it had never been granted, and none of the * endpoints its own extension had published. */ const FIELDS = [ { name: 'numero', type: 'string' }, { name: 'montant', type: 'decimal' }, ] function rowFor(operation: 'read' | 'create' | 'update' | 'delete') { const [row] = catalogRowsFor({ applicationCode: 'crm', moduleCode: 'ventes', sectionCode: 'factures', entityName: 'Facture', operations: [operation], maxPageSize: 500, }) return row } function build(operation: 'read' | 'create' | 'update' | 'delete', modifiedSinceFilter = false) { return contractFor({ row: rowFor(operation), fields: FIELDS, modifiedSinceFilter, entityName: 'Facture', }) } describe('public API contract', () => { it('states the HTTP verb, which AccessType cannot express', () => { // Read/Write is all the catalogue column says, so `…-update` and `…-delete` both read as // "Write" and every consumer emitted POST for them — a 405 the integrator discovers at runtime. expect(build('read').httpMethods).toEqual(['GET']) expect(build('create').httpMethods).toEqual(['POST']) expect(build('update').httpMethods).toEqual(['PUT']) expect(build('delete').httpMethods).toEqual(['DELETE']) }) it('declares tenantId as REQUIRED on every operation', () => { // The export surface answers 400 without it. A collection that omits the parameter produces // requests that fail on the first try, which is exactly what shipped before. for (const operation of ['read', 'create', 'update', 'delete'] as const) { const tenantId = build(operation).parameters.find(p => p.name === 'tenantId') expect(tenantId, `${operation} must declare tenantId`).toBeDefined() expect(tenantId!.required).toBe(true) expect(tenantId!.in).toBe('query') } }) it('paginates a read, and only a read', () => { expect(build('read').paginated).toBe(true) expect(build('read').parameters.map(p => p.name)).toContain('pageSize') expect(build('update').paginated).toBe(false) expect(build('update').parameters.map(p => p.name)).not.toContain('pageSize') }) it('advertises modifiedSince ONLY when the query really supports it', () => { // Filtering a page after pagination drops rows and lies about the total, so the platform emits // the parameter only when `Get{Plural}Query` carries the member. Advertising it otherwise sends // an integrator down a sync path that silently returns unfiltered pages. expect(build('read', false).parameters.map(p => p.name)).not.toContain('modifiedSince') expect(build('read', true).parameters.map(p => p.name)).toContain('modifiedSince') }) it('describes the answer with the resource identity first, then its members', () => { expect(build('read').responseFields.map(f => f.name)).toEqual(['id', 'numero', 'montant']) }) it('carries no translation key — nobody can translate a client business label', () => { // The platform seeds a `descriptionKey` beside each of its OWN labels so they follow the // signed-in language. A generated contract carries the plain text alone, and the consumers // render `t(key, { defaultValue: description })`, so text-only is a complete contract. const contract = build('read', true) for (const parameter of contract.parameters) { expect(parameter.descriptionKey).toBeUndefined() expect(parameter.description).toBeTruthy() } }) it('stamps the shape version the platform reads', () => { expect(build('read').version).toBe(PUBLIC_API_CONTRACT_VERSION) }) }) describe('serialization — the wire shape shared with the platform', () => { /** * PINNED CROSS-LANGUAGE FIXTURE. * * The very same literal is asserted by the platform's * `ApiEndpointContractTests.AContractGeneratedByTheCli_IsReadable` (SmartStack.app). It is the * ONLY thing proving the two languages agree on the wire shape: the CLI writes this string into a * generated seed provider, and the platform's `ApiEndpointContractSerializer.TryParse` reads it. * A silent disagreement does not throw — it parses to null, and every surface quietly stops * describing the endpoint. * * EDIT BOTH SIDES OR NEITHER. */ const PINNED_UPDATE_CONTRACT = '{"version":1,"httpMethods":["PUT"],"paginated":false,' + '"parameters":[{"name":"tenantId","type":"UUID","in":"query","required":true,' + '"description":"ID du tenant — obligatoire : un appelant machine n\'a pas de tenant ambiant, ' + 'l\'export est refusé sans lui (400)"}],' + '"responseFields":[{"name":"id","type":"UUID","description":"Identifiant unique de Facture"},' + '{"name":"numero","type":"string"},{"name":"montant","type":"decimal"}]}' it('emits exactly the JSON the platform parses', () => { expect(serializeContract(build('update'))).toBe(PINNED_UPDATE_CONTRACT) }) it('omits absent members rather than writing nulls', () => { // Mirrors the platform's JsonIgnoreCondition.WhenWritingNull: the blob is stored on every // catalogue row, and `"descriptionKey":null` on each field is pure weight. const json = serializeContract(build('read')) expect(json).not.toContain('null') expect(json).not.toContain('descriptionKey') expect(json).not.toContain('notes') }) it('is stable, so a seeded row does not churn a migration at every build', () => { expect(serializeContract(build('read'))).toBe(serializeContract(build('read'))) }) it('round-trips through JSON.parse with its declared members intact', () => { const parsed = JSON.parse(serializeContract(build('update'))) expect(parsed.httpMethods).toEqual(['PUT']) expect(parsed.paginated).toBe(false) expect(parsed.parameters[0].required).toBe(true) }) })