/** * scaffold-tests / generate — regression net for the client-run defects: * date/enum factory literals, the implicit trailing tenantId factory argument, * the namespace segment and the tenant/owner-aware service constructor. * scaffold-tests had NO colocated tests before — every one of those shipped * broken while the suites of its producer siblings stayed green. */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import { ScaffoldTestsInputSchema, type ScaffoldTestsInput } from '../types.js' function fixture(overrides: Record = {}): ScaffoldTestsInput { return ScaffoldTestsInputSchema.parse({ layer: 'domain', module: 'parametrage', appCode: 'Demo', applicationCode: 'flotte', entities: [{ name: 'AlertRule', pluralName: 'AlertRules', fields: [ { name: 'Label', type: 'string', required: true }, { name: 'ValidFrom', type: 'date', required: true }, { name: 'RecipientMode', type: 'RecipientMode', required: true, enumValues: ['Email', 'Sms'] }, { name: 'Notes', type: 'string', required: false }, ], }], projectPath: '/proj', ...overrides, }) } const fileOf = (files: ReturnType, suffix: string) => files.find(f => f.path.endsWith(suffix))!.content describe('scaffold-tests / domain — literal mappers (date + enum) and the implicit tenantId', () => { it('a DateOnly factory parameter gets new DateOnly(2026, 1, 1), an enum its first declared member', () => { const src = fileOf(generate(fixture()), 'Domain/AlertRuleTests.cs') expect(src).toContain('new DateOnly(2026, 1, 1)') expect(src).toContain('RecipientMode.Email') expect(src).not.toContain('"test"') // the historical uncompilable fallback expect(src).toContain('ValidFrom.Should().Be(new DateOnly(2026, 1, 1))') expect(src).toContain('RecipientMode.Should().Be(RecipientMode.Email)') }) it('tenantMode strict (the default) appends the trailing Guid.NewGuid() factory argument', () => { const src = fileOf(generate(fixture()), 'Domain/AlertRuleTests.cs') expect(src).toContain('AlertRule.Create("Test", new DateOnly(2026, 1, 1), RecipientMode.Email, Guid.NewGuid())') }) it('tenantMode none appends nothing', () => { const src = fileOf(generate(fixture({ tenantMode: 'none' })), 'Domain/AlertRuleTests.cs') expect(src).toContain('AlertRule.Create("Test", new DateOnly(2026, 1, 1), RecipientMode.Email)') expect(src).not.toContain('RecipientMode.Email, Guid.NewGuid())') }) it('an enum without declared values falls back to the target-typed default literal', () => { const files = generate(fixture({ entities: [{ name: 'DueDateType', pluralName: 'DueDateTypes', fields: [ { name: 'Code', type: 'string', required: true }, { name: 'Regime', type: 'RecurrenceRegime', required: true }, ], }], })) const src = fileOf(files, 'Domain/DueDateTypeTests.cs') expect(src).toContain('DueDateType.Create("Test", default, Guid.NewGuid())') }) it('no required string → the ArgumentException test is NOT emitted (nothing would throw)', () => { const files = generate(fixture({ entities: [{ name: 'VatRate', pluralName: 'VatRates', fields: [{ name: 'Rate', type: 'decimal', required: true }], }], })) const src = fileOf(files, 'Domain/VatRateTests.cs') expect(src).not.toContain('Create_WithInvalidParams_ShouldThrow') expect(src).toContain('Create_WithValidParams_ShouldSucceed') }) }) describe('scaffold-tests / business — namespaces + tenant/owner-aware service ctor', () => { const bizFixture = (overrides: Record = {}) => fixture({ layer: 'business', ...overrides }) it('usings carry the segment scaffold-business actually emits', () => { const src = fileOf(generate(bizFixture()), 'Application/AlertRuleHandlersTests.cs') expect(src).toContain('using Demo.Application.Flotte.Parametrage.Commands;') expect(src).not.toContain('using Demo.Application.Parametrage') }) it('a tenant-aware service (default strict) is constructed with (context, tenantService) — the SAME double as the DbContext', () => { const files = generate(bizFixture()) const svc = files.find(f => f.path.endsWith('Application/AlertRuleServiceTests.cs') || f.path.includes('AlertRuleService'))!.content expect(svc).toContain('var tenantService = new FakeCurrentTenantService(Guid.NewGuid());') expect(svc).toContain('new AlertRuleService(context, tenantService)') }) it('tenantMode none constructs with the context alone', () => { const files = generate(bizFixture({ tenantMode: 'none' })) const svc = files.find(f => f.path.includes('AlertRuleService'))!.content expect(svc).toContain('new AlertRuleService(context)') }) it('a tenant-scoped module emits the cross-tenant isolation fact (two tenants, one store)', () => { const files = generate(bizFixture()) const svc = files.find(f => f.path.includes('AlertRuleService'))!.content expect(svc).toContain('GetByIdAsync_RowOfAnotherTenant_IsInvisible') // Both contexts share ONE store — the isolation comes from the named filter, // never from separate databases. expect(svc.match(/new ExtensionsDbContext\(options, tenantService\)/g)!.length).toBeGreaterThanOrEqual(2) expect(svc).toContain('dto.Should().BeNull(') }) it('tenantMode none emits no cross-tenant isolation fact (no filter to exercise)', () => { const files = generate(bizFixture({ tenantMode: 'none' })) const svc = files.find(f => f.path.includes('AlertRuleService'))!.content expect(svc).not.toContain('GetByIdAsync_RowOfAnotherTenant_IsInvisible') }) it('a data-scoped entity adds the FakeCurrentUserAccessor and EXCLUDES the owner from Create args', () => { const files = generate(bizFixture({ entities: [{ name: 'Mission', pluralName: 'Missions', dataScope: { mode: 'own', ownerProperty: 'OwnerUserId' }, fields: [ { name: 'Label', type: 'string', required: true }, { name: 'OwnerUserId', type: 'guid', required: true }, ], }], })) expect(files.some(f => f.path.endsWith('Tests/Common/FakeCurrentUserAccessor.cs'))).toBe(true) const svc = files.find(f => f.path.includes('MissionService'))!.content expect(svc).toContain('new MissionService(context, tenantService, new FakeCurrentUserAccessor(Guid.NewGuid()))') expect(svc).toContain('new CreateMissionCommand("Test")') expect(svc).not.toContain('CreateMissionCommand("Test", Guid.NewGuid())') }) }) describe('scaffold-tests / api — DiResolutionTests container gate', () => { // The leg no build, audit or unit test exercises: a generated service whose // AddScoped never landed ships every endpoint as a runtime 500 behind green // gates. The api layer emits one MemberData theory per controller resolving // its ctor from the REAL container (WebApplicationFactory, no HTTP). it('emits Tests/Integration/DiResolutionTests.cs alongside the Common files', () => { const files = generate(fixture({ layer: 'api' })) const di = files.find(f => f.path === 'Tests/Integration/DiResolutionTests.cs')! expect(di).toBeDefined() expect(di.content).toContain('[Collection("Integration")]') expect(di.content).toContain('typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract') expect(di.content).toContain('ActivatorUtilities.CreateInstance(scope.ServiceProvider, controllerType)') expect(di.content).toContain('act.Should().NotThrow(') // Boots the real factory (socle + client DI), same fixture as the API tests. expect(di.content).toContain('new DemoWebAppFactory(db.ConnectionString)') }) }) // ── API permission facts (audit chantier 1.5 — the un-failable probe is gone) ─ describe('scaffold-tests / api — permission facts', () => { const apiFixture = (entityOverrides: Record = {}) => fixture({ layer: 'api', entities: [{ name: 'AlertRule', pluralName: 'AlertRules', fields: [{ name: 'Label', type: 'string', required: true }], ...entityOverrides, }], }) it('always emits the DENIED facts — exact 403/401, no OK escape hatch', () => { const src = fileOf(generate(apiFixture()), 'Api/AlertRulesControllerTests.cs') expect(src).toContain('GetAll_WithoutPermission_IsDenied') expect(src).toContain('Create_WithoutPermission_IsDenied') expect(src).toContain('HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized') // The vacuous probe that accepted every outcome is GONE: expect(src).not.toContain('GetAll_Authenticated_HitsTheRealRoute') expect(src).not.toContain('HttpStatusCode.OK, HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden') }) it('emits the positive fact with the EXACT 4-seg permission when section is declared', () => { const src = fileOf(generate(apiFixture({ section: 'alert-rules' })), 'Api/AlertRulesControllerTests.cs') expect(src).toContain('GetAll_WithReadPermission_IsNotForbidden') expect(src).toContain('Authenticated("flotte.parametrage.alert-rules.read")') expect(src).toContain('NotBe(HttpStatusCode.Forbidden)') // The legacy invalid "{module}.view" claim no longer rides the probes: expect(src).not.toContain('parametrage.view') }) it('without section: no positive fact, no guessed permission — denied facts only', () => { const src = fileOf(generate(apiFixture()), 'Api/AlertRulesControllerTests.cs') expect(src).not.toContain('GetAll_WithReadPermission_IsNotForbidden') // The unknown-id probe keeps the legacy claim (asserts only "no 5xx"). expect(src).toContain('GetById_UnknownId_DoesNotServerError') }) it('the INVALID `{module}.view` literal is gone from every probe (bare token instead)', () => { // The vacuous-probe removal denounced the literal, but the unknown-id // probe's fallback kept emitting it (conformity-audit finding). Without a // section the probe now authenticates with NO claim at all. const src = fileOf(generate(apiFixture()), 'Api/AlertRulesControllerTests.cs') expect(src).not.toContain('.view') expect(src).toContain('Authenticated()') }) it('hasCreate: false (read-only entity) skips the Create denied fact — a 404/405 is not a guard failure', () => { const src = fileOf(generate(apiFixture({ hasCreate: false })), 'Api/AlertRulesControllerTests.cs') expect(src).not.toContain('Create_WithoutPermission_IsDenied') // The read-side twin always stays: expect(src).toContain('GetAll_WithoutPermission_IsDenied') }) }) // ── Rule tests carry the [Trait("BR", …)] parity tag (DEV-TEST-009 lockstep) ─ describe('scaffold-tests / business — rule tests are BR-tagged', () => { it('emits [Trait("BR","BR-001")] on the rule test — the exact tag DEV-TEST-009 counts', () => { const files = generate(fixture({ layer: 'business', businessRules: [{ id: 'BR-001', entityName: 'AlertRule', description: 'Amount must be positive', invalidExamples: ['amount -5 rejected'], }], })) const src = fileOf(files, 'Application/CreateAlertRuleCommandValidatorTests.cs') expect(src).toContain('[Trait("BR", "BR-001")]') expect(src).toContain('Validate_Enforces_BR_001') }) })