import { describe, expect, it } from 'vitest' import { generate, legacyPaths } from '../generate.js' import { validate } from '../validate.js' import { ScaffoldExternalApiInputSchema, type GeneratedFile } from '../types.js' const BASE = { appCode: 'TestV2', applicationCode: 'crm', applicationPascal: 'Crm', projectPath: 'D:/app', resources: [ { entity: 'Facture', module: 'ventes', section: 'factures', operations: ['read', 'create', 'update', 'delete'], naturalKey: ['numero'], fields: [ { name: 'numero', type: 'string', required: true }, { name: 'montant', type: 'decimal', required: true }, { name: 'clotureLe', type: 'DateTime?', required: false, phase: 'closed' }, ], }, ], } const gen = (raw: unknown) => generate(ScaffoldExternalApiInputSchema.parse(raw)) function byName(files: GeneratedFile[], suffix: string): GeneratedFile { const f = files.find(x => x.path.endsWith(suffix)) if (!f) throw new Error(`file not found: ${suffix}\nhave:\n${files.map(x => x.path).join('\n')}`) return f } describe('scaffold-external-api — controllers', () => { const { files } = gen(BASE) it('emits one controller per catalogue code, plus the catalogue seed provider', () => { expect(files.map(f => f.path.split('/').pop())).toEqual([ 'FacturesPublicController.cs', 'FacturesCreatePublicController.cs', 'FacturesUpdatePublicController.cs', 'FacturesDeletePublicController.cs', 'CrmPublicApiCatalogSeedDataProvider.cs', ]) }) it('routes under the whitelisted prefix and NEVER declares a NavRoute', () => { const read = byName(files, 'FacturesPublicController.cs') expect(read.content).toContain('[Route("api/v1/export/crm-factures")]') // The header explains why a [NavRoute] is forbidden, so match the ATTRIBUTE // (a line that starts with it), never the prose. expect(read.content).not.toMatch(/^\s*\[NavRoute\(/m) const create = byName(files, 'FacturesCreatePublicController.cs') expect(create.content).toContain('[Route("api/v1/export/crm-factures-create")]') }) it('lands under a Public/ segment so audit-dev-api leaves the SPA rules out of it', () => { expect(byName(files, 'FacturesPublicController.cs').path).toContain('/Public/') }) it('imports ICurrentUserService from its REAL namespace', () => { // Found by compiling the generated code against the platform assemblies: // ICurrentUserService lives in …Common.Interfaces.Identity, not in // …Common.Interfaces. The bare namespace compiles nowhere — CS0246 on every // public controller — and no amount of re-reading the middleware that uses // it reveals that, because the middleware sits in another assembly. for (const f of files.filter(x => x.path.endsWith('PublicController.cs'))) { expect(f.content).toContain('using SmartStack.Application.Common.Interfaces.Identity;') expect(f.content).not.toMatch(/^using SmartStack\.Application\.Common\.Interfaces;$/m) } }) it('carries the full class-level guard block', () => { const read = byName(files, 'FacturesPublicController.cs') expect(read.content).toContain('[Microsoft.AspNetCore.Authorization.Authorize]') expect(read.content).toContain('[EnableRateLimiting(ExternalAppRateLimitExtensions.PolicyName)]') expect(read.content).toContain('[RequiresLicenseFeature(LicenseFeatures.ApiAccess)]') expect(read.content).toContain('[ApiExplorerSettings(GroupName = "public")]') expect(read.content).toContain('// @generated-by scaffold-external-api') }) it('guards every action with the permission its catalogue row requires', () => { expect(byName(files, 'FacturesPublicController.cs').content) .toContain('[RequirePermission(VentesPermissions.Factures.Read)]') expect(byName(files, 'FacturesCreatePublicController.cs').content) .toContain('[RequirePermission(VentesPermissions.Factures.Create)]') expect(byName(files, 'FacturesUpdatePublicController.cs').content) .toContain('[RequirePermission(VentesPermissions.Factures.Update)]') expect(byName(files, 'FacturesDeletePublicController.cs').content) .toContain('[RequirePermission(VentesPermissions.Factures.Delete)]') }) it('never re-emits the permission constants — the nested Section class is not partial', () => { expect(files.some(f => f.path.includes('Permissions'))).toBe(false) }) it('takes tenantId on EVERY action and binds it before touching the service', () => { for (const f of files.filter(x => x.path.endsWith('PublicController.cs'))) { const actions = f.content.split(/\n \[Http/).slice(1) expect(actions.length).toBeGreaterThan(0) for (const action of actions) { expect(action).toContain('[FromQuery] Guid tenantId') expect(action).toContain('await BindTenantAsync(tenantId, ct)') } } }) it('refuses an arbitrary tenantId for a signed-in human, honours it for an external app', () => { const read = byName(files, 'FacturesPublicController.cs').content expect(read).toContain('if (_currentUser.IsExternalApp)') expect(read).toContain('await _tenant.SetByIdAsync(tenantId, ct)') expect(read).toContain('if (_tenant.TenantId != tenantId)') expect(read).toContain('StatusCodes.Status403Forbidden') }) it('answers 400 on a missing tenantId rather than reading unscoped', () => { expect(byName(files, 'FacturesPublicController.cs').content).toContain('if (tenantId == Guid.Empty)') }) it('returns the platform export envelope, and errors as ProblemDetails', () => { const read = byName(files, 'FacturesPublicController.cs').content expect(read).toContain('PaginatedExportResult') expect(read).not.toContain('PaginatedResult>>') expect(read).toContain('[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]') }) it('reuses the SAME service as the other strata — no duplicated business logic', () => { for (const f of files.filter(x => x.path.endsWith('PublicController.cs'))) { expect(f.content).toContain('private readonly IFactureService _service;') } }) it('clamps the page size the response-buffering middleware has to hold', () => { expect(byName(files, 'FacturesPublicController.cs').content).toContain('Math.Clamp(pageSize, 1, 1000)') }) it('omits modifiedSince unless the query really carries the member', () => { expect(byName(files, 'FacturesPublicController.cs').content).not.toContain('modifiedSince') const withFilter = gen({ ...BASE, resources: [{ ...BASE.resources[0], modifiedSinceFilter: true }], }) const read = byName(withFilter.files, 'FacturesPublicController.cs').content expect(read).toContain('[FromQuery] DateTime? modifiedSince = null,') expect(read).toContain('ModifiedSince: modifiedSince') }) it('excludes lifecycle-phased fields from Create but keeps them on Update', () => { const create = byName(files, 'FacturesCreatePublicController.cs').content expect(create).toContain('new CreateFactureCommand(dto.Numero, dto.Montant)') const update = byName(files, 'FacturesUpdatePublicController.cs').content expect(update).toContain('new UpdateFactureCommand(id, dto.Numero, dto.Montant, dto.ClotureLe)') }) it('points Location at the READ code — create and read are different controllers', () => { expect(byName(files, 'FacturesCreatePublicController.cs').content) .toContain('Created($"/api/v1/export/crm-factures/{id}?tenantId={tenantId}", id)') }) it('forwards the concurrency token on a versioned entity', () => { const versioned = gen({ ...BASE, resources: [{ ...BASE.resources[0], versioned: true }] }) expect(byName(versioned.files, 'FacturesUpdatePublicController.cs').content).toContain(', dto.RowVersion)') }) it('resource granularity collapses to ONE controller serving every verb', () => { const { files: rf } = gen({ ...BASE, resources: [{ ...BASE.resources[0], granularity: 'resource' }] }) const controllers = rf.filter(f => f.path.endsWith('PublicController.cs')) expect(controllers).toHaveLength(1) const only = controllers[0].content expect(only).toContain('[Route("api/v1/export/crm-factures")]') expect(only).toContain('[HttpGet]') expect(only).toContain('[HttpPost]') expect(only).toContain('[HttpDelete("{id:guid}")]') }) }) describe('scaffold-external-api — catalogue seed provider', () => { const { files, catalogue } = gen(BASE) const provider = byName(files, 'CrmPublicApiCatalogSeedDataProvider.cs').content it('runs after the Core providers so the navigation rows it resolves already exist', () => { expect(provider).toContain('public int Order => 30;') }) it('seeds one row per catalogue code, with the permission the action enforces', () => { expect(provider).toContain('Code: "crm-factures"') expect(provider).toContain('RequiredPermission: "crm.ventes.factures.read"') expect(provider).toContain('Code: "crm-factures-create"') expect(provider).toContain('RequiredPermission: "crm.ventes.factures.create"') expect(provider).toContain('ApiEndpointAccessType.Write') expect(catalogue.map(c => c.requiredPermission)).toEqual([ 'crm.ventes.factures.read', 'crm.ventes.factures.create', 'crm.ventes.factures.update', 'crm.ventes.factures.delete', ]) }) it('sets the two REQUIRED navigation FKs the domain factory leaves empty', () => { expect(provider).toContain('added.Property(nameof(DataApiEndpoint.NavigationApplicationId)).CurrentValue = navApp.Id;') expect(provider).toContain('added.Property(nameof(DataApiEndpoint.NavigationModuleId)).CurrentValue = navModule.Id;') }) it('skips silently when navigation is not seeded yet — a throw would abort the boot', () => { expect(provider).toContain('if (navApp is null) return;') expect(provider).toContain('if (navModule is null) continue;') }) it('realigns a drifted row instead of leaving a silent 403/404', () => { expect(provider).toContain('if (current.RequiredPermission != row.RequiredPermission)') expect(provider).toContain('if (current.RouteTemplate != row.RouteTemplate)') }) it('never creates a grant — that stays an admin act', () => { // The summary says so in prose; what matters is that the grant DbSet is // never touched. expect(provider).not.toContain('context.ExternalApplicationApiAccesses') expect(provider).not.toMatch(/ExternalApplicationApiAccess\.Create/) }) }) describe('scaffold-external-api — DI + legacy sweep', () => { it('registers the provider between per-application markers', () => { const { diRegistration } = gen(BASE) expect(diRegistration.markerBlock).toContain('<<< PUBLIC-API-SEED-DI-Crm BEGIN >>>') expect(diRegistration.markerBlock).toContain('AddScoped>>') }) it('lists the controllers of operations no longer published', () => { const readOnly = ScaffoldExternalApiInputSchema.parse({ ...BASE, resources: [{ ...BASE.resources[0], operations: ['read'] }], }) const swept = legacyPaths(readOnly).map(p => p.split('/').pop()) expect(swept).toEqual([ 'FacturesCreatePublicController.cs', 'FacturesUpdatePublicController.cs', 'FacturesDeletePublicController.cs', ]) expect(swept).not.toContain('FacturesPublicController.cs') }) }) describe('scaffold-external-api — validate', () => { it('accepts the reference spec', () => { const r = validate(BASE) expect(r.errors).toEqual([]) expect(r.valid).toBe(true) }) it('refuses two resources claiming the same catalogue code', () => { const r = validate({ ...BASE, resources: [ BASE.resources[0], { ...BASE.resources[0], entity: 'Avoir', module: 'compta', operations: ['read'] }, ], }) expect(r.errors.join('\n')).toMatch(/UNIQUE database-wide/) }) it('refuses a code the platform already owns', () => { const r = validate({ ...BASE, applicationCode: 'users', resources: [{ ...BASE.resources[0], section: '', operations: ['read'] }], }) expect(r.valid).toBe(false) }) it('refuses a write surface with no way to read back', () => { const r = validate({ ...BASE, resources: [{ ...BASE.resources[0], operations: ['create'] }] }) expect(r.errors.join('\n')).toMatch(/without read/) }) it('refuses create/update with no fields — the command call would not compile', () => { const r = validate({ ...BASE, resources: [{ ...BASE.resources[0], fields: [], naturalKey: [] }], }) expect(r.errors.join('\n')).toMatch(/fields\[\] is empty/) }) it('refuses a naturalKey member absent from fields[]', () => { const r = validate({ ...BASE, resources: [{ ...BASE.resources[0], naturalKey: ['reference'] }] }) expect(r.errors.join('\n')).toMatch(/naturalKey member "reference" is not among fields/) }) it('warns — never silently accepts — the wildcard that resource granularity forces', () => { const r = validate({ ...BASE, resources: [{ ...BASE.resources[0], granularity: 'resource' }] }) expect(r.valid).toBe(true) expect(r.warnings.join('\n')).toMatch(/crm\.ventes\.factures\.\*/) expect(r.warnings.join('\n')).toMatch(/granted retroactively/) }) it('warns when create ships without a natural key', () => { const r = validate({ ...BASE, resources: [{ ...BASE.resources[0], naturalKey: [] }] }) expect(r.warnings.join('\n')).toMatch(/retried POST creates a duplicate/) }) })