/** * cli:scaffold-tests — generate.ts * Generates test files per layer following SmartStack.app test conventions. * Tests consume NuGet packages (SmartStack.Core, SmartStack.Application) and standard * .NET test libraries (xUnit, FluentAssertions, Moq, FluentValidation.TestHelper, * Microsoft.EntityFrameworkCore.InMemory). Frontend tests consume npm @atlashub/smartstack. */ import { appSegment, applicationNs, servicesNs } from '../../../../lib/app-classification.js' import type { ScaffoldTestsInput, GeneratedFile, EntityTestSpec, BusinessRuleTest } from './types.js' export function generate(spec: ScaffoldTestsInput): GeneratedFile[] { switch (spec.layer) { case 'seed': return generateSeedTests(spec) case 'domain': return generateDomainTests(spec) case 'business': return generateBusinessTests(spec) case 'api': return generateApiTests(spec) case 'frontend': return generateFrontendTests(spec) } } /** * Pre-classification locations (no `` segment) of this layer's test files. * The CLI deletes any that exist before writing the new `Tests///…` * files, so re-running /ba-develop MOVES the tests — a stale test file left at * the old path would reference the now-moved (renamed) Application/Service * namespaces and break the test build. `Tests/Common/…` is shared and never moved. */ export function legacyPaths(spec: ScaffoldTestsInput): string[] { const backendPrefix = `Tests/${appSegment(spec.applicationCode)}/` const fePrefix = `tests/${spec.applicationCode.toLowerCase()}/` return generate(spec).map(f => f.path.startsWith(backendPrefix) ? `Tests/${f.path.slice(backendPrefix.length)}` : f.path.startsWith(fePrefix) ? `tests/${f.path.slice(fePrefix.length)}` : f.path, ) } function generateSeedTests(spec: ScaffoldTestsInput): GeneratedFile[] { const ns = spec.namespace ?? `${spec.appCode}.Tests.${appSegment(spec.applicationCode)}.${capitalize(spec.module)}` const className = `${capitalize(spec.module)}SeedDataProviderTests` const providerClass = `${capitalize(spec.module)}SeedDataProvider` const content = `using Xunit; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SmartStack.Application.Common.Interfaces; using SmartStack.Infrastructure.Persistence; using ${spec.appCode}.Infrastructure.Seeding; namespace ${ns}.Seed; [Trait("Category", "Seed")] [Trait("Type", "Unit")] public class ${className} { private static CoreDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new CoreDbContext(options); } [Fact] public async Task SeedNavigationAsync_ShouldBeIdempotent() { await using var context = CreateContext(); var provider = new ${providerClass}(); await provider.SeedNavigationAsync(context, TestContext.Current.CancellationToken); var firstCount = await context.NavigationApplications.CountAsync(); await provider.SeedNavigationAsync(context, TestContext.Current.CancellationToken); var secondCount = await context.NavigationApplications.CountAsync(); secondCount.Should().Be(firstCount, "seed must be idempotent across multiple invocations"); } [Fact] public async Task SeedNavigationAsync_ShouldCreateFullHierarchy() { await using var context = CreateContext(); var provider = new ${providerClass}(); await provider.SeedNavigationAsync(context, TestContext.Current.CancellationToken); var apps = await context.NavigationApplications.ToListAsync(); var modules = await context.NavigationModules.ToListAsync(); apps.Should().NotBeEmpty("the provider must seed at least one application"); modules.Should().NotBeEmpty("the provider must seed at least one module"); modules.Should().OnlyContain(m => apps.Any(a => a.Id == m.ApplicationId), "every module must reference a seeded application"); } [Fact] public async Task SeedPermissionsAsync_ShouldCoverAllSections() { await using var context = CreateContext(); var provider = new ${providerClass}(); await provider.SeedNavigationAsync(context, TestContext.Current.CancellationToken); await provider.SeedPermissionsAsync(context, TestContext.Current.CancellationToken); var sections = await context.NavigationSections.Select(s => s.Code).ToListAsync(); var permissionPaths = await context.NavigationPermissions.Select(p => p.Path).ToListAsync(); foreach (var section in sections) { permissionPaths.Should().Contain(p => p.Contains(section), $"section '{section}' must have at least one permission"); } } [Fact] public async Task SeedRolePermissionsAsync_ShouldMapAllRoles() { await using var context = CreateContext(); var provider = new ${providerClass}(); await provider.SeedNavigationAsync(context, TestContext.Current.CancellationToken); await provider.SeedPermissionsAsync(context, TestContext.Current.CancellationToken); await provider.SeedRolesAsync(context, TestContext.Current.CancellationToken); await provider.SeedRolePermissionsAsync(context, TestContext.Current.CancellationToken); var roles = await context.Roles.ToListAsync(); var rolePermissions = await context.RolePermissions.ToListAsync(); roles.Should().NotBeEmpty(); foreach (var role in roles) { rolePermissions.Should().Contain(rp => rp.RoleId == role.Id, $"role '{role.Code}' must have at least one mapped permission"); } } } ` return [{ path: `Tests/${appSegment(spec.applicationCode)}/${capitalize(spec.module)}/Seed/${className}.cs`, content }] } function generateDomainTests(spec: ScaffoldTestsInput): GeneratedFile[] { return spec.entities.map(entity => { const ns = spec.namespace ?? `${spec.appCode}.Tests.${appSegment(spec.applicationCode)}.${capitalize(spec.module)}` const className = `${entity.name}Tests` const requiredFields = entity.fields.filter(f => f.required) // scaffold-entity appends an implicit trailing `Guid tenantId` factory // parameter whenever tenantMode != 'none' (its default is 'strict') — // every factory call below must line up with it. const tenantArg = spec.tenantMode !== 'none' ? (requiredFields.length > 0 ? ', Guid.NewGuid()' : 'Guid.NewGuid()') : '' const validArgs = requiredFields.map(f => getDefaultValue(f)).join(', ') + tenantArg const nullArgs = requiredFields.map(f => getNullValue(f)).join(', ') + tenantArg // The factory guard (`ArgumentException.ThrowIfNullOrWhiteSpace`) is only // emitted for REQUIRED STRING fields — without one the all-invalid call // throws nothing and the test fails by construction. const hasRequiredString = requiredFields.some(f => f.type.toLowerCase() === 'string') const invalidParamsTest = hasRequiredString ? ` [Fact] public void Create_WithInvalidParams_ShouldThrow() { var act = () => ${entity.name}.Create(${nullArgs}); act.Should().Throw(); } ` : '' const content = `using Xunit; using FluentAssertions; using ${spec.appCode}.Domain.Entities; namespace ${ns}.Domain; [Trait("Category", "Domain")] [Trait("Type", "Unit")] public class ${className} { #region Create [Fact] public void Create_WithValidParams_ShouldSucceed() { var before = DateTime.UtcNow; var entity = ${entity.name}.Create(${validArgs}); entity.Should().NotBeNull(); // ExtensionBaseEntity assigns Id + CreatedAt at construction (the // package BaseEntity leaves both unset until SaveChanges). entity.Id.Should().NotBeEmpty(); entity.CreatedAt.Should().BeOnOrAfter(before); ${requiredFields.map(f => ` entity.${capitalize(f.name)}.Should().${getAssertionForType(f)};`).join('\n')} } ${invalidParamsTest} [Fact] public void Create_ShouldGenerateUniqueIds() { var a = ${entity.name}.Create(${validArgs}); var b = ${entity.name}.Create(${validArgs}); a.Id.Should().NotBe(b.Id); } #endregion } ` return { path: `Tests/${appSegment(spec.applicationCode)}/${capitalize(spec.module)}/Domain/${className}.cs`, content } }) } function generateBusinessTests(spec: ScaffoldTestsInput): GeneratedFile[] { const files: GeneratedFile[] = [] const app = spec.appCode const mod = capitalize(spec.module) const ns = spec.namespace ?? `${app}.Tests.${appSegment(spec.applicationCode)}.${mod}` // test namespace // Production namespaces — the SAME canonical helpers scaffold-business uses // (lib/app-classification.ts). Hand-building them here used to drop the // segment (`{app}.Application.{Module}` vs the emitted // `{app}.Application.{App}.{Module}`) → CS0246 on every using of all 36 // generated business test files. const appNs = applicationNs(app, spec.applicationCode, spec.module) const svcNs = servicesNs(app, spec.applicationCode, spec.module) // Shared owner test-double — emitted ONCE when any entity is data-scoped // (the service ctor then takes ICurrentUserAccessor). if (spec.entities.some(en => en.dataScope)) { files.push({ path: `Tests/Common/FakeCurrentUserAccessor.cs`, content: `using SmartStack.Application.Common.Interfaces.Identity; namespace ${app}.Tests.Common; /// /// Test double for . Pins a fixed user id so /// data-scoped services resolve a stable owner in unit tests. /// public sealed class FakeCurrentUserAccessor : ICurrentUserAccessor { public FakeCurrentUserAccessor(Guid? userId = null) => UserId = userId ?? Guid.NewGuid(); public bool HasRequestContext => true; public Guid? UserId { get; } public bool HasPermission(string permission) => true; } `, }) } // Shared tenant test-double — emitted ONCE. Pins a fixed tenant id so the // named "Tenant" row filter (mounted by scaffold-entity in ExtensionsDbContext, // TENANT-FILTERS markers) is ACTIVE in the in-memory tests — EF Core InMemory // HONOURS query filters, which is what lets the per-entity cross-tenant // isolation fact fail on an unmounted filter. files.push({ path: `Tests/Common/FakeCurrentTenantService.cs`, content: `using SmartStack.Application.Common.Interfaces.Tenants; using SmartStack.Domain.Platform.Administration.Tenants; namespace ${app}.Tests.Common; /// /// Test double for . Pins a fixed tenant id so /// the ExtensionsDbContext tenant query filter is exercised in unit tests. /// public sealed class FakeCurrentTenantService : ICurrentTenantService { public FakeCurrentTenantService(Guid? tenantId = null) => TenantId = tenantId ?? Guid.NewGuid(); public Guid? TenantId { get; private set; } public string? TenantSlug => null; public Tenant? Tenant => null; public bool HasTenant => TenantId.HasValue; public bool HasGlobalAccess => false; public bool IsGlobalScope => !TenantId.HasValue; public Task SetBySlugAsync(string slug, CancellationToken cancellationToken = default) => Task.FromResult(false); public Task SetByIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { TenantId = tenantId; return Task.FromResult(true); } public void SetTenant(Tenant? tenant) { } public void SetGlobalAccess(bool hasGlobalAccess) { } public void Clear() => TenantId = null; } `, }) for (const entity of spec.entities) { const e = entity.name const entityRules = spec.businessRules.filter(r => r.entityName === e) const requiredFields = entity.fields.filter(f => f.required) // Mirror of scaffold-business `createInputFields`: the owner column of a // data-scoped entity is server-resolved (anti-spoof) and NOT part of // Create{E}Command — including it is a wrong-arity CS7036. const ownerName = entity.dataScope?.ownerProperty ?? null const createInputFields = requiredFields.filter(f => f.name !== ownerName) const defaultArgs = createInputFields.map(f => getDefaultValue(f)).join(', ') const hasRequiredString = createInputFields.some(f => f.type.toLowerCase() === 'string') // Mirror of scaffold-business `serviceDeps`: (IExtensionsDbContext // [, ICurrentTenantService][, ICurrentUserAccessor]). Passing `context` // alone to a tenant-aware service is CS7036 — the SAME tenant double the // DbContext got keeps the query filter and the service's tenant coherent. const serviceCtorArgs = [ 'context', ...(spec.tenantMode !== 'none' ? ['tenantService'] : []), ...(ownerName ? ['new FakeCurrentUserAccessor(Guid.NewGuid())'] : []), ].join(', ') // ── Handler tests — the handler is a thin delegator to I{E}Service, so we // mock the service and Verify the delegation (canonical "no-query handler"). ── files.push({ path: `Tests/${appSegment(spec.applicationCode)}/${mod}/Application/${e}HandlersTests.cs`, content: `using Xunit; using FluentAssertions; using Moq; using ${appNs}.Commands; using ${appNs}.Handlers; using ${appNs}.Interfaces; namespace ${ns}.Application; [Trait("Category", "Business")] [Trait("Type", "Unit")] public class ${e}HandlersTests { private readonly Mock _serviceMock = new(); #region Create${e}CommandHandler [Fact] public async Task Handle_ValidCommand_DelegatesToServiceCreate() { // Arrange var command = new Create${e}Command(${defaultArgs}); var expectedId = Guid.NewGuid(); _serviceMock .Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(expectedId); var handler = new Create${e}CommandHandler(_serviceMock.Object); // Act var result = await handler.Handle(command, TestContext.Current.CancellationToken); // Assert result.Should().Be(expectedId); _serviceMock.Verify(s => s.CreateAsync(command, It.IsAny()), Times.Once); } #endregion #region Delete${e}CommandHandler [Fact] public async Task Handle_DeleteCommand_DelegatesToServiceDelete() { // Arrange var id = Guid.NewGuid(); var handler = new Delete${e}CommandHandler(_serviceMock.Object); // Act await handler.Handle(new Delete${e}Command(id), TestContext.Current.CancellationToken); // Assert _serviceMock.Verify(s => s.DeleteAsync(id, It.IsAny()), Times.Once); } #endregion } `, }) // ── Service tests — real in-memory ExtensionsDbContext + fixed tenant. ── files.push({ path: `Tests/${appSegment(spec.applicationCode)}/${mod}/Application/${e}ServiceTests.cs`, content: `using Xunit; using FluentAssertions; using Microsoft.EntityFrameworkCore; using ${app}.Infrastructure.Persistence; using ${svcNs}; using ${appNs}.Commands; using ${app}.Domain.Entities; using ${app}.Tests.Common; namespace ${ns}.Application; [Trait("Category", "Business")] [Trait("Type", "Unit")] public class ${e}ServiceTests { // In-memory ExtensionsDbContext, wired with a fixed tenant so the tenant query // filter is exercised. EF Core InMemory HONOURS query filters; it does NOT enforce // relational constraints (FK/unique), SQL translation, or SqlObjects/TVF — those are // covered by the integration tests on real SQL Server LocalDB. private static ${e}Service CreateService(out ExtensionsDbContext context) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var tenantService = new FakeCurrentTenantService(Guid.NewGuid()); context = new ExtensionsDbContext(options, tenantService); return new ${e}Service(${serviceCtorArgs}); } #region CreateAsync [Fact] public async Task CreateAsync_ValidCommand_PersistsEntity() { // Arrange var service = CreateService(out var context); var command = new Create${e}Command(${defaultArgs}); // Act var id = await service.CreateAsync(command, TestContext.Current.CancellationToken); // Assert id.Should().NotBeEmpty(); var persisted = await context.Set<${e}>().FindAsync(new object[] { id }, TestContext.Current.CancellationToken); persisted.Should().NotBeNull(); } #endregion #region GetByIdAsync [Fact] public async Task GetByIdAsync_AfterCreate_ReturnsTheEntity() { // Arrange var service = CreateService(out _); var id = await service.CreateAsync(new Create${e}Command(${defaultArgs}), TestContext.Current.CancellationToken); // Act var dto = await service.GetByIdAsync(id, TestContext.Current.CancellationToken); // Assert dto.Should().NotBeNull(); dto!.Id.Should().Be(id); } #endregion #region DeleteAsync [Fact] public async Task DeleteAsync_AfterCreate_RemovesTheEntity() { // Arrange var service = CreateService(out var context); var id = await service.CreateAsync(new Create${e}Command(${defaultArgs}), TestContext.Current.CancellationToken); // Act await service.DeleteAsync(id, TestContext.Current.CancellationToken); // Assert var afterDelete = await context.Set<${e}>().FindAsync(new object[] { id }, TestContext.Current.CancellationToken); afterDelete.Should().BeNull(); } #endregion${spec.tenantMode !== 'none' ? ` #region Tenant isolation // The named "Tenant" query filter — mounted by scaffold-entity in // ExtensionsDbContext (TENANT-FILTERS markers; DEV-API-032) — is the ONLY // thing keeping generated reads tenant-scoped: extension entities carry no // automatic tenant filter. This fact FAILS on a context missing the // ApplyNamed{Strict|Optional}TenantFilter<${e}> line. [Fact] public async Task GetByIdAsync_RowOfAnotherTenant_IsInvisible() { // Arrange — ONE shared in-memory store, two tenant-bound contexts: // the row is written under tenant B, the read runs under tenant A. var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; Guid foreignId; { var tenantService = new FakeCurrentTenantService(Guid.NewGuid()); var context = new ExtensionsDbContext(options, tenantService); var serviceB = new ${e}Service(${serviceCtorArgs}); foreignId = await serviceB.CreateAsync(new Create${e}Command(${defaultArgs}), TestContext.Current.CancellationToken); } { var tenantService = new FakeCurrentTenantService(Guid.NewGuid()); var context = new ExtensionsDbContext(options, tenantService); var serviceA = new ${e}Service(${serviceCtorArgs}); // Act var dto = await serviceA.GetByIdAsync(foreignId, TestContext.Current.CancellationToken); // Assert dto.Should().BeNull("a row of another tenant must be invisible to every read"); } } #endregion` : ''} } `, }) // ── Validator tests — required fields + one [Fact] per business rule. ── const requiredFieldTests = createInputFields.map(f => { const invalidArgs = createInputFields.map(rf => rf.name === f.name ? getNullValue(rf) : getDefaultValue(rf)) return ` [Fact] public void Validate_Empty${capitalize(f.name)}_ShouldFail() { var command = new Create${e}Command(${invalidArgs.join(', ')}); var result = _validator.TestValidate(command); result.ShouldHaveValidationErrorFor(x => x.${capitalize(f.name)}); }` }).join('\n\n') // One [Fact] per business rule. Gated on having ≥1 required string field so the // all-empty command is genuinely rejected by a NotEmpty rule — a real assertion, // never a stub. Refine each to assert the SPECIFIC field/error once modelled. const ruleTests = hasRequiredString ? entityRules.map(rule => ` /// ${rule.description} [Fact] [Trait("BR", "${rule.id}")] public void Validate_Enforces_${sanitizeMethodName(rule.id)}() { // Business rule ${rule.id}: ${rule.description} ${(rule.invalidExamples.length > 0 ? rule.invalidExamples.map(ex => ` // Invalid: ${ex}`).join('\n') : ' // Invalid: an incomplete command must be rejected.')} var invalid = new Create${e}Command(${createInputFields.map(f => getNullValue(f)).join(', ')}); _validator.TestValidate(invalid).IsValid.Should().BeFalse("rule '${rule.id}' must reject an invalid command"); }`).join('\n\n') : '' files.push({ path: `Tests/${appSegment(spec.applicationCode)}/${mod}/Application/Create${e}CommandValidatorTests.cs`, content: `using Xunit; using FluentAssertions; using FluentValidation.TestHelper; using ${appNs}.Commands; using ${appNs}.Validators; namespace ${ns}.Application; [Trait("Category", "Business")] [Trait("Type", "Unit")] public class Create${e}CommandValidatorTests { private readonly Create${e}CommandValidator _validator = new(); [Fact] public void Validate_ValidCommand_ShouldPass() { var command = new Create${e}Command(${defaultArgs}); var result = _validator.TestValidate(command); result.ShouldNotHaveAnyValidationErrors(); } ${requiredFieldTests}${ruleTests ? '\n\n' + ruleTests : ''} } `, }) } return files } /** The exact 4-seg read permission when the entity declares its `section`. */ function entityReadPermission( spec: ScaffoldTestsInput, entity: ScaffoldTestsInput['entities'][number], ): string | null { if (!entity.section) return null return `${spec.applicationCode}.${spec.module.toLowerCase()}.${entity.section}.read` } /** Token permissions for the neutral probes (GetById unknown-id). */ function readPermissionArg( spec: ScaffoldTestsInput, entity: ScaffoldTestsInput['entities'][number], ): string { const perm = entityReadPermission(spec, entity) // No section declared → NO claim at all (a bare Authenticated() token): the // probe only asserts "no 5xx", and an unauthorised 403 is < 500. The old // fallback emitted the INVALID literal `{module}.view` (neither a // vocabulary action nor a valid path) into shipped tests — the exact string // the vacuous-probe removal denounced (conformity-audit finding). return perm ? `"${perm}"` : '' } /** * Positive permission fact — emitted only when the entity declares its * `section` (the permission path is then exact, never guessed): a token * carrying the endpoint's own read permission must NOT be Forbidden. A 403 * here means the wiring is broken (wrong constant / rebound path / missing * seed row) — the sharp claim the old vacuous * `BeOneOf(OK, Unauthorized, Forbidden)` probe could never make. */ function positivePermissionTest( spec: ScaffoldTestsInput, entity: ScaffoldTestsInput['entities'][number], routeBase: string, ): string { const perm = entityReadPermission(spec, entity) if (!perm) return '' return ` [Fact] public async Task GetAll_WithReadPermission_IsNotForbidden() { await _db.ResetAsync(); // The token carries the EXACT permission the endpoint requires — a 403 // here means the permission WIRING is broken (wrong constant, rebound // path), never a legitimate denial. 401 stays possible when the API // enforces a server-side session; 200 is the healthy outcome; 5xx is // always a failure. var client = Authenticated("${perm}"); var response = await client.GetAsync("${routeBase}"); response.StatusCode.Should().NotBe(HttpStatusCode.Forbidden); ((int)response.StatusCode).Should().BeLessThan(500); } ` } /** The write-side DEV-API-033 twin — skipped for a read-only entity * (`hasCreate: false`): without an [HttpPost] the response is 404/405, a red * fact no permission guard caused. */ function createDeniedTest( entity: ScaffoldTestsInput['entities'][number], routeBase: string, ): string { if (entity.hasCreate === false) return '' return ` [Fact] public async Task Create_WithoutPermission_IsDenied() { await _db.ResetAsync(); // Authorization filters run BEFORE model binding — the empty body can // never turn this into a 400: a guarded [HttpPost] rejects the // unauthorised caller first. Same DEV-API-033 twin, write side. var client = Authenticated(); using var body = new StringContent("{}", System.Text.Encoding.UTF8, "application/json"); var response = await client.PostAsync("${routeBase}", body); response.StatusCode.Should().BeOneOf( HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized); } ` } function generateApiTests(spec: ScaffoldTestsInput): GeneratedFile[] { const app = spec.appCode const mod = capitalize(spec.module) const ns = spec.namespace ?? `${app}.Tests.${appSegment(spec.applicationCode)}.${mod}` const files: GeneratedFile[] = [] // Integration harness — emitted ONCE (idempotent), shared by every module. // REAL SQL Server LocalDB + Respawn + a WebApplicationFactory whose JWT is // overridden via PostConfigure. This layer catches what EF InMemory cannot // (relational constraints, SQL translation, SqlObjects/TVF). files.push(...integrationHarnessFiles(app)) for (const entity of spec.entities) { const className = `${entity.pluralName}ControllerTests` const routeBase = `/api/${spec.module.toLowerCase()}/${entity.pluralName.toLowerCase()}` files.push({ path: `Tests/${appSegment(spec.applicationCode)}/${mod}/Api/${className}.cs`, content: `using System.Net; using System.Net.Http.Headers; using FluentAssertions; using Xunit; using ${app}.Tests.Common; namespace ${ns}.Api; [Trait("Category", "Integration")] [Trait("Type", "Integration")] [Collection("Integration")] public class ${className} : IAsyncDisposable { private readonly DatabaseFixture _db; private readonly ${app}WebAppFactory _factory; private readonly HttpClient _client; public ${className}(DatabaseFixture db) { _db = db; _factory = new ${app}WebAppFactory(db.ConnectionString); _client = _factory.CreateClient(); } private HttpClient Authenticated(params string[] permissions) { var client = _factory.CreateClient(); var token = JwtTokenHelper.GenerateToken(permissions: permissions); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return client; } [Fact] public async Task GetAll_Unauthenticated_Returns401() { // No token → the API auth pipeline rejects before reaching the route. var response = await _client.GetAsync("${routeBase}"); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] public async Task GetAll_WithoutPermission_IsDenied() { await _db.ResetAsync(); // Authenticated but carrying ZERO permission claims: a guarded endpoint // must reject — 403, or 401 when the API additionally enforces a // server-side session. It can NEVER be 200: this fact FAILS when the // endpoint shipped without [RequirePermission] (the runtime twin of // audit rule DEV-API-033 — an unguarded endpoint is open to every // authenticated caller). var client = Authenticated(); var response = await client.GetAsync("${routeBase}"); response.StatusCode.Should().BeOneOf( HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized); } ${createDeniedTest(entity, routeBase)}${positivePermissionTest(spec, entity, routeBase)} [Fact] public async Task GetById_UnknownId_DoesNotServerError() { await _db.ResetAsync(); var client = Authenticated(${readPermissionArg(spec, entity)}); var response = await client.GetAsync($"${routeBase}/{Guid.NewGuid()}"); // The route exists and handles an unknown id without crashing (no 5xx). ((int)response.StatusCode).Should().BeLessThan(500); } public async ValueTask DisposeAsync() { _client.Dispose(); await _factory.DisposeAsync(); } } `, }) } return files } /** * The shared integration harness (emitted once into Tests/Common/). A real * SQL Server LocalDB + Respawn + a WebApplicationFactory whose JWT validation is * overridden via PostConfigure (NOT config — read too early by AddInfrastructure). * * Requires these NuGet packages in the client test project: Respawn, * Microsoft.Data.SqlClient, Microsoft.AspNetCore.Mvc.Testing, * System.IdentityModel.Tokens.Jwt. * * Execution pitfalls (documented for the client): * - A running dev API locks the build output → MSB3021. Stop it, or * `dotnet test -p:BuildProjectReferences=false`. * - Use `npm install` (not `npm ci`) while a Vite dev server runs * (@tailwindcss/oxide lock). */ function integrationHarnessFiles(app: string): GeneratedFile[] { const databaseFixture = `using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Respawn; using Xunit; using ${app}.Infrastructure.Persistence; namespace ${app}.Tests.Common; /// /// Per-collection SQL Server LocalDB fixture. Creates a throwaway database, /// applies migrations, and resets the 'extensions' schema between tests with /// Respawn. A real relational engine — catches FK/unique constraints, SQL /// translation and SqlObjects/TVF that EF InMemory cannot. /// ADJUST: the LocalDB instance / connection string for your CI. /// public sealed class DatabaseFixture : IAsyncLifetime { public string ConnectionString { get; } = $"Server=(localdb)\\\\MSSQLLocalDB;Database=Ext_IntTests_{Guid.NewGuid():N};Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=true"; private Respawner? _respawner; public async ValueTask InitializeAsync() { await using var ctx = CreateContext(); await ctx.Database.MigrateAsync(); await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); _respawner = await Respawner.CreateAsync(conn, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, SchemasToInclude = new[] { "extensions" }, WithReseed = true, }); } /// Reset the extension tables to empty between tests. public async Task ResetAsync() { if (_respawner is null) return; await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); await _respawner.ResetAsync(conn); } /// A context bound to the test database, in GLOBAL scope (no tenant filter) for seeding. public ExtensionsDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseSqlServer(ConnectionString) .Options; return new ExtensionsDbContext(options); } public async ValueTask DisposeAsync() { await using var ctx = CreateContext(); await ctx.Database.EnsureDeletedAsync(); } } /// Bind every integration test class to the single LocalDB fixture. [CollectionDefinition("Integration")] public sealed class IntegrationCollection : ICollectionFixture { } ` const webAppFactory = `using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using System.Text; using ${app}.Infrastructure.Persistence; namespace ${app}.Tests.Common; /// /// In-process API host bound to the integration test database. JWT validation is /// overridden via PostConfigure — the ONLY reliable hook, because AddInfrastructure /// reads the JWT config before ConfigureAppConfiguration runs. Tests mint tokens /// with using the same key. /// /// ADJUST: the config keys (Jwt:*, ConnectionStrings:*) and the DbContextOptions /// service type to match your API's registration. Requires the API project to /// expose 'public partial class Program { }' and be referenced by this test project. /// public sealed class ${app}WebAppFactory : WebApplicationFactory { private readonly string _connectionString; public ${app}WebAppFactory(string connectionString) => _connectionString = connectionString; protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Development"); builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["ConnectionStrings:DefaultConnection"] = _connectionString, ["Jwt:Secret"] = JwtTokenHelper.Secret, ["Jwt:Issuer"] = JwtTokenHelper.Issuer, ["Jwt:Audience"] = JwtTokenHelper.Audience, }); }); builder.ConfigureTestServices(services => { // Point ExtensionsDbContext at the integration test database. var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); if (descriptor is not null) services.Remove(descriptor); services.AddDbContext(o => o.UseSqlServer(_connectionString)); // Override JWT AFTER AddJwtBearer — config-based override is read too early. var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtTokenHelper.Secret)); services.PostConfigure(JwtBearerDefaults.AuthenticationScheme, o => { o.TokenValidationParameters.IssuerSigningKey = key; o.TokenValidationParameters.ValidIssuer = JwtTokenHelper.Issuer; o.TokenValidationParameters.ValidAudience = JwtTokenHelper.Audience; o.TokenValidationParameters.ValidateIssuerSigningKey = true; }); }); } } ` const jwtHelper = `using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; namespace ${app}.Tests.Common; /// /// HMAC-SHA256 token minting for integration tests — the same key the WebAppFactory /// installs via PostConfigure, so the tokens validate against the in-process host. /// public static class JwtTokenHelper { public const string Secret = "IntegrationTestSecretKeyThatIsAtLeast256BitsLongForHmacSha256Validation!"; public const string Issuer = "SmartStack"; public const string Audience = "SmartStack"; public static string GenerateToken(Guid? userId = null, string? email = null, IEnumerable? permissions = null) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new List { new(JwtRegisteredClaimNames.Sub, (userId ?? Guid.NewGuid()).ToString()), new(JwtRegisteredClaimNames.Email, email ?? "test@smartstack.local"), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), }; foreach (var permission in permissions ?? Array.Empty()) claims.Add(new Claim("permission", permission)); var token = new JwtSecurityToken( issuer: Issuer, audience: Audience, claims: claims, expires: DateTime.UtcNow.AddHours(1), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } } ` // Container gate — the leg NO build, audit or unit test exercises. A // generated service whose AddScoped never landed passes every other gate // and turns every request into a runtime 500 ("Unable to resolve service // for type 'I{E}Service'"). One theory per controller, no HTTP involved: // ActivatorUtilities.CreateInstance resolves the ctor dependencies from the // REAL container (socle + client registrations) booted by the factory. const diResolutionTests = `using FluentAssertions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using System.Reflection; using Xunit; using ${app}.Tests.Common; namespace ${app}.Tests.Integration; /// /// Every controller of the API assembly must be constructible from the real DI /// container. Catches an unregistered generated service (AddScoped missing in /// the BUSINESS-SERVICES-DI block) at CI time, per controller, without HTTP — /// the "green build, green audits, every endpoint 500" class. /// [Trait("Category", "Integration")] [Trait("Type", "Integration")] [Collection("Integration")] public sealed class DiResolutionTests : IAsyncDisposable { private readonly ${app}WebAppFactory _factory; public DiResolutionTests(DatabaseFixture db) => _factory = new ${app}WebAppFactory(db.ConnectionString); public static IEnumerable ControllerTypes() => typeof(Program).Assembly .GetTypes() .Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract) .Select(t => new object[] { t }); [Theory] [MemberData(nameof(ControllerTypes))] public void Controller_resolves_from_the_container(Type controllerType) { using var scope = _factory.Services.CreateScope(); var act = () => ActivatorUtilities.CreateInstance(scope.ServiceProvider, controllerType); act.Should().NotThrow( $"every ctor dependency of {controllerType.Name} must be registered — a missing AddScoped " + "turns EVERY request to this controller into a 500 at runtime"); } public async ValueTask DisposeAsync() => await _factory.DisposeAsync(); } ` return [ { path: `Tests/Common/DatabaseFixture.cs`, content: databaseFixture }, { path: `Tests/Common/${app}WebAppFactory.cs`, content: webAppFactory }, { path: `Tests/Common/JwtTokenHelper.cs`, content: jwtHelper }, { path: `Tests/Integration/DiResolutionTests.cs`, content: diResolutionTests }, ] } function generateFrontendTests(spec: ScaffoldTestsInput): GeneratedFile[] { return spec.entities.map(entity => { const entityLower = entity.name.charAt(0).toLowerCase() + entity.name.slice(1) const content = `import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; import { SmartStackProvider } from '@atlashub/smartstack'; // vi.mock is hoisted — declare it ABOVE the page import so the page picks the mock // up. react-i18next is ALWAYS mocked (no real i18n instance → no NO_I18NEXT_INSTANCE // warning); t(key) returns the key, so assertions match on the i18n key. vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en', changeLanguage: vi.fn() } }), })); vi.mock('@/api/${spec.module}/use${entity.pluralName}', () => ({ use${entity.pluralName}: () => ({ data: { items: [], total: 0 }, isLoading: false, error: null }), })); import { ${entity.name}ListPage } from '@/pages/${spec.module}/${entityLower}/${entity.name}ListPage'; function renderWithProviders(ui: React.ReactElement) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); return render( {ui} ); } describe('${entity.name}ListPage', () => { beforeEach(() => { vi.clearAllMocks(); }); it('renders the page heading', async () => { renderWithProviders(<${entity.name}ListPage />); expect(await screen.findByRole('heading')).toBeInTheDocument(); }); it('renders an empty list when the data source returns no items', async () => { renderWithProviders(<${entity.name}ListPage />); // The mocked hook returns zero items → no data rows should be rendered. await screen.findByRole('heading'); expect(screen.queryByTestId('${entityLower}-row')).not.toBeInTheDocument(); }); }); ` return { path: `tests/${spec.applicationCode}/${spec.module}/${entityLower}/${entity.name}.test.tsx`, content } }) } // ─── Helpers ─── function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1) } function sanitizeMethodName(s: string): string { return s.replace(/[^a-zA-Z0-9]/g, '_') } /** A field as the literal mappers need it — `type` plus the optional declared * enum members. Mirrors the EntityTestSpecSchema field shape. */ interface TestField { name: string; type: string; enumValues?: string[] } function getDefaultValue(f: TestField): string { switch (f.type.toLowerCase()) { case 'string': return '"Test"' case 'int': case 'integer': case 'number': return '1' case 'decimal': return '100m' case 'bool': case 'boolean': return 'true' case 'datetime': return 'DateTime.UtcNow' case 'date': case 'dateonly': return 'new DateOnly(2026, 1, 1)' case 'guid': return 'Guid.NewGuid()' default: // Non-primitive: a C# enum (scaffold-entity passes the type through // verbatim). First declared member when the spec carries the values; // the target-typed `default` literal otherwise — both compile, the // historical '"test"' string never did (CS1503 on every enum/DateOnly // factory parameter). return f.enumValues && f.enumValues.length > 0 ? `${f.type}.${f.enumValues[0]}` : 'default' } } function getNullValue(f: TestField): string { switch (f.type.toLowerCase()) { case 'string': return 'null!' case 'int': case 'integer': case 'number': return '0' case 'decimal': return '0m' case 'bool': case 'boolean': return 'false' case 'datetime': return 'default' case 'date': case 'dateonly': return 'default' case 'guid': return 'Guid.Empty' default: return 'default' } } function getAssertionForType(f: TestField): string { switch (f.type.toLowerCase()) { case 'string': return `Be("Test")` case 'int': case 'integer': case 'number': return `Be(1)` case 'decimal': return `Be(100m)` case 'bool': case 'boolean': return `BeTrue()` case 'datetime': return `BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5))` case 'date': case 'dateonly': return `Be(new DateOnly(2026, 1, 1))` case 'guid': return `NotBeEmpty()` default: return f.enumValues && f.enumValues.length > 0 ? `Be(${f.type}.${f.enumValues[0]})` : `Be(default)` } }