import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { buildGuide, buildOpenApi, buildPostman, diffContracts, openApiType, schemaOf } from '../build.js' import { collectEndpoints, execute, parseDtoRecords } from '../execute.js' import { validate } from '../validate.js' import { PublishApiContractInputSchema } from '../types.js' import type { ContractEndpoint, DtoField } from '../types.js' import { generate } from '../../scaffold-external-api/generate.js' import { ScaffoldExternalApiInputSchema } from '../../scaffold-external-api/types.js' const EP: ContractEndpoint = { code: 'crm-factures', route: '/api/v1/export/crm-factures', verbs: ['GET'], requiredPermission: 'crm.ventes.factures.read', accessType: 'Read', entity: 'Facture', rateLimitPerMinute: 60, maxPageSize: 1000, listDto: 'FactureListDto', detailDto: 'FactureDetailDto', createDto: 'CreateFactureDto', updateDto: 'UpdateFactureDto', } const DTOS = new Map([ ['FactureListDto', [{ name: 'Id', csType: 'Guid' }, { name: 'Numero', csType: 'string' }, { name: 'Montant', csType: 'decimal' }]], ['FactureDetailDto', [{ name: 'Id', csType: 'Guid' }, { name: 'Commentaire', csType: 'string?' }]], ]) describe('publish-api-contract — C# to OpenAPI', () => { it('maps the types a generated DTO actually uses', () => { expect(openApiType('Guid')).toEqual({ type: 'string', format: 'uuid' }) expect(openApiType('decimal')).toEqual({ type: 'number' }) expect(openApiType('DateTime?')).toEqual({ type: 'string', format: 'date-time', nullable: true }) expect(openApiType('List')).toEqual({ type: 'array', items: { type: 'string' } }) }) it('degrades an unknown type to string rather than dropping the field', () => { expect(openApiType('SomeEnum')).toEqual({ type: 'string' }) }) it('marks non-nullable members required, and camel-cases the wire names', () => { const schema = schemaOf(DTOS.get('FactureListDto')!) as { properties: Record; required: string[] } expect(Object.keys(schema.properties)).toEqual(['id', 'numero', 'montant']) expect(schema.required).toEqual(['id', 'numero', 'montant']) const detail = schemaOf(DTOS.get('FactureDetailDto')!) as { required?: string[] } expect(detail.required).toEqual(['id']) }) it('parses the positional members of a generated record', () => { const parsed = parseDtoRecords( 'public record FactureListDto(Guid Id, string Numero, decimal? Montant);\npublic record Ignored(int X);', ) expect([...parsed.keys()]).toEqual(['FactureListDto']) expect(parsed.get('FactureListDto')).toEqual([ { csType: 'Guid', name: 'Id' }, { csType: 'string', name: 'Numero' }, { csType: 'decimal?', name: 'Montant' }, ]) }) }) describe('publish-api-contract — the document a third party integrates against', () => { const doc = buildOpenApi([EP], DTOS, { title: 'CRM public API', version: 'v1', baseUrl: 'https://api.example.com' }) it('declares the bearer security scheme the platform actually uses', () => { const components = doc.components as { securitySchemes: Record> } expect(components.securitySchemes.bearerAuth).toMatchObject({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }) expect(doc.security).toEqual([{ bearerAuth: [] }]) }) it('makes tenantId a REQUIRED query parameter on every operation', () => { const paths = doc.paths as Record> }>> for (const item of Object.values(paths)) { for (const op of Object.values(item)) { const tenant = op.parameters.find(p => p.name === 'tenantId') expect(tenant).toBeDefined() expect(tenant!.required).toBe(true) } } }) it('publishes the real response schema, not just the route', () => { const components = doc.components as { schemas: Record } expect(components.schemas.FactureListDto).toBeDefined() expect(components.schemas.PaginatedFactureList).toBeDefined() }) it('documents the failure codes specific to this surface', () => { const paths = doc.paths as Record }>> const get = paths['/api/v1/export/crm-factures'].get expect(get.responses['403'].description).toMatch(/route_blocked/) expect(get.responses['404'].description).toMatch(/endpoint_not_found/) expect(get.responses['429']).toBeDefined() }) it('advertises the server-side page cap the controller enforces', () => { const paths = doc.paths as Record> }>> const pageSize = paths['/api/v1/export/crm-factures'].get.parameters.find(p => p.name === 'pageSize') expect((pageSize!.schema as Record).maximum).toBe(1000) }) }) describe('publish-api-contract — Postman + guide', () => { it('ships the token exchange as the first request', () => { const collection = buildPostman([EP], { title: 'CRM', baseUrl: 'https://api.example.com' }) const items = collection.item as Array<{ name: string }> expect(items[0].name).toMatch(/Obtain a token/) expect(items[1].name).toBe('GET crm-factures') }) it('says the four things the platform contract does not', () => { const guide = buildGuide([EP], { title: 'CRM', version: 'v1' }) expect(guide).toMatch(/`\?tenantId=` est \*\*obligatoire\*\*/) expect(guide).toMatch(/figées dans le jeton/) expect(guide).toMatch(/Idempotency-Key.*ne fonctionne pas/) expect(guide).toMatch(/permission_mismatch/) }) }) describe('publish-api-contract — the breaking-change gate', () => { const base = buildOpenApi([EP], DTOS, { title: 'T', version: 'v1', baseUrl: 'u' }) it('sees nothing to compare on a first publish', () => { expect(diffContracts(null, base)).toEqual({ additive: [], breaking: [] }) }) it('calls a new endpoint additive', () => { const next = buildOpenApi([EP, { ...EP, code: 'crm-avoirs', route: '/api/v1/export/crm-avoirs', entity: 'Avoir' }], DTOS, { title: 'T', version: 'v1', baseUrl: 'u' }) const diff = diffContracts(base, next) expect(diff.breaking).toEqual([]) expect(diff.additive.join()).toMatch(/new path \/api\/v1\/export\/crm-avoirs/) }) it('calls a removed endpoint breaking', () => { const diff = diffContracts(base, buildOpenApi([], DTOS, { title: 'T', version: 'v1', baseUrl: 'u' })) expect(diff.breaking.join()).toMatch(/path removed/) }) it('calls a removed response field breaking — the client reads it', () => { const shrunk = new Map(DTOS) shrunk.set('FactureListDto', [{ name: 'Id', csType: 'Guid' }]) const diff = diffContracts(base, buildOpenApi([EP], shrunk, { title: 'T', version: 'v1', baseUrl: 'u' })) expect(diff.breaking.join()).toMatch(/field removed: FactureListDto\.numero/) }) }) describe('publish-api-contract — on real scaffolder output', () => { let root: string beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-xapi-')) const spec = ScaffoldExternalApiInputSchema.parse({ appCode: 'TestV2', applicationCode: 'crm', applicationPascal: 'Crm', projectPath: root, resources: [ { entity: 'Facture', module: 'ventes', section: 'factures', operations: ['read', 'create'], naturalKey: ['numero'], fields: [{ name: 'numero', type: 'string', required: true }], }, ], }) for (const file of generate(spec).files) { const dest = path.join(root, file.path) fs.mkdirSync(path.dirname(dest), { recursive: true }) fs.writeFileSync(dest, file.content, 'utf8') } const dtoDir = path.join(root, 'src/TestV2.Application/Crm/Ventes/DTOs') fs.mkdirSync(dtoDir, { recursive: true }) fs.writeFileSync( path.join(dtoDir, 'FactureDtos.cs'), [ 'public record FactureListDto(Guid Id, string Numero, decimal Montant);', 'public record FactureDetailDto(Guid Id, string Numero, string? Commentaire);', 'public record CreateFactureDto(string Numero, decimal Montant);', ].join('\n'), 'utf8', ) }) afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) }) it('collects only endpoints that have a catalogue row', async () => { const endpoints = await collectEndpoints(path.join(root, 'src'), 'crm') expect(endpoints.map(e => e.code)).toEqual(['crm-factures', 'crm-factures-create']) expect(endpoints[0].verbs).toEqual(['GET']) expect(endpoints[1].verbs).toEqual(['POST']) }) it('writes the three artefacts and picks up the real DTO shapes', async () => { const { report, errors } = await execute( PublishApiContractInputSchema.parse({ projectPath: root, applicationCode: 'crm', baseUrl: 'https://api.example.com' }), ) expect(errors).toEqual([]) expect(report.files).toEqual([ '.smartstack/api-public/v1/openapi.json', '.smartstack/api-public/v1/postman_collection.json', '.smartstack/api-public/v1/guide-integration.md', ]) const doc = JSON.parse(fs.readFileSync(path.join(root, '.smartstack/api-public/v1/openapi.json'), 'utf8')) expect(doc.components.schemas.FactureListDto.properties).toHaveProperty('numero') }) it('refuses to republish a removed endpoint under the same version', async () => { const spec = PublishApiContractInputSchema.parse({ projectPath: root, applicationCode: 'crm', baseUrl: 'https://api.example.com' }) await execute(spec) // The partner integration is now live; the create endpoint disappears. fs.rmSync(path.join(root, 'src/TestV2.Api/Controllers/Crm/Ventes/Public/FacturesCreatePublicController.cs')) const second = await execute(spec) expect(second.errors.join('\n')).toMatch(/would BREAK an integration already holding v1/) expect(second.report.wrote).toBe(false) }) it('lets the same removal through under a new version', async () => { await execute(PublishApiContractInputSchema.parse({ projectPath: root, applicationCode: 'crm', baseUrl: 'https://x' })) fs.rmSync(path.join(root, 'src/TestV2.Api/Controllers/Crm/Ventes/Public/FacturesCreatePublicController.cs')) const v2 = await execute( PublishApiContractInputSchema.parse({ projectPath: root, applicationCode: 'crm', version: 'v2', baseUrl: 'https://x' }), ) expect(v2.errors).toEqual([]) expect(v2.report.wrote).toBe(true) }) it('warns when the baseUrl is still a placeholder', () => { expect(validate({ projectPath: root }).warnings.join()).toMatch(/\{host\} placeholder/) }) })