/** * test-data.test.ts — scaffold-seed `testData[]`, the business test dataset * (jeu-de-test.md) as the SECOND seed tier: guarded by the socle's own * dev-seeding switch, ordered after every module's reference data, every FK * resolved by key inside the tenant BEFORE Create(), a missing reference = * a skipped, logged row — never Guid.Empty. And the anti-regression pin: * `testData` absent → byte-identical output. */ import { describe, expect, it } from 'vitest' import { generate, seedDiPatches } from '../generate.js' import { validate } from '../validate.js' import { ScaffoldSeedInputSchema } from '../types.js' function baseSpec(extra: Record = {}): unknown { return { module: 'annuaire', appCode: 'Client', applicationCode: 'client', projectPath: '/tmp/x', roles: [{ name: 'Commercial', code: 'commercial', applicationCode: 'client' }], testUsers: [{ roleCode: 'commercial' }], ...extra, } } const TYPE_CLIENT = { ref: 'CLIENT/CONFIGURATION/TypeClient', entity: 'TypeClient', keyField: 'Label', key: 'Grand compte' } const SEGMENT = { ref: 'CLIENT/CONFIGURATION/Segment', entity: 'Segment', keyField: 'Nom', key: 'Industrie' } const COMMERCIAL = { actor: 'BA-001-AC-002', label: 'Commercial' } const ACME = { core: 'TenantOrganisation', by: 'Name', value: 'ACME SA' } const CLIENT_ROWS = [ { Nom: 'Direction Marketing', TypeClientId: TYPE_CLIENT, SegmentId: SEGMENT, ResponsableId: COMMERCIAL, OrganisationId: ACME, Statut: 'Actif', Actif: true }, { Nom: 'Boulangerie Favre', TypeClientId: TYPE_CLIENT, SegmentId: null, ResponsableId: COMMERCIAL, OrganisationId: ACME, Statut: 'Archivé', Actif: false }, ] const testData = (rank = 1) => ({ testDataRank: rank, testData: [ { entity: 'Client', keyField: 'Nom', types: { Statut: 'ClientStatut' }, rows: CLIENT_ROWS }, { entity: 'Contact', keyField: 'Email', rows: [{ Email: 'claire@exemple.ch', ClientId: { ref: 'CLIENT/ANNUAIRE/Client', entity: 'Client', keyField: 'Nom', key: 'Direction Marketing' } }] }, ], }) const providerOf = (spec: unknown) => generate(ScaffoldSeedInputSchema.parse(spec)).find(f => f.path.endsWith('AnnuaireTestDataSeedDataProvider.cs')) describe('scaffold-seed / testData — the guarded second tier', () => { it('emits {Module}TestDataSeedDataProvider guarded by IsDevelopment() || SmartStack:EnableDevSeeding, Order 200 + rank', () => { const p = providerOf(baseSpec(testData(2)))! expect(p).toBeDefined() expect(p.path).toBe('src/Client.Infrastructure/Persistence/Seeding/Applications/Client/Modules/Annuaire/AnnuaireTestDataSeedDataProvider.cs') expect(p.content).toContain('public int Order => 202;') expect(p.content).toContain('if (!_env.IsDevelopment() && !_config.GetValue("SmartStack:EnableDevSeeding"))') expect(p.content).toContain('ignoré (SmartStack:EnableDevSeeding=false)') expect(p.content).toContain('IExtensionsDbContext extensions,') expect(p.content).toContain('IHostEnvironment env,') expect(p.content).toContain('IConfiguration config,') expect(p.content).toContain('ILogger logger') expect(p.content).not.toContain('SmartStackOptions') }) it('is DI-registered (an unregistered provider is inert)', () => { const lines = seedDiPatches(ScaffoldSeedInputSchema.parse(baseSpec(testData()))).map(p => p.line) expect(lines.some(l => l.includes('AnnuaireTestDataSeedDataProvider'))).toBe(true) }) it('resolves every FK by key INSIDE the tenant before Create(), and skips + logs a missing one', () => { const c = providerOf(baseSpec(testData()))!.content expect(c).toContain('var typeClientId = await _extensions.Set().Where(x => x.Label == "Grand compte" && x.TenantId == tenantId).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);') expect(c).toContain('if (typeClientId == null) { _logger.LogWarning("Jeu de test {Entity} « {Key} » : FK {Target} « {Value} » introuvable — ligne sautée", "Client", "Direction Marketing", "TypeClient", "Grand compte"); _skipped++; }') expect(c).toContain('else if (segmentId == null)') expect(c).toContain('typeClientId: typeClientId.Value') expect(c).toContain('segmentId: segmentId.Value') expect(c).not.toContain('Guid.Empty') // Upsert by natural key inside the tenant, counted. expect(c).toContain('else if (!await _extensions.Set().AnyAsync(x => x.Nom == "Direction Marketing" && x.TenantId == tenantId, ct))') expect(c).toContain('_loaded++;') }) it('a User cited by ACTOR resolves to the role test user by email; a Core organisation by Name (global or tenant)', () => { const c = providerOf(baseSpec(testData()))!.content expect(c).toContain('var responsableId = await context.Users.Where(u => u.Email == "commercial.test@smartstack.local").Select(u => (Guid?)u.Id).FirstOrDefaultAsync(ct);') expect(c).toContain('acteur {Actor} (test user {Email}) introuvable') expect(c).toContain('var organisationId = await context.TenantOrganisations.Where(x => x.Name == "ACME SA" && (x.TenantId == null || x.TenantId == tenantId)).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);') expect(c).toContain('réessayé au prochain démarrage') }) it('a null FK cell is passed as null; typed enum cells become members; SaveChanges after each entity, in order', () => { const c = providerOf(baseSpec(testData()))!.content expect(c).toContain('segmentId: null') expect(c).toContain('statut: ClientStatut.Actif') expect(c).toContain('actif: false') const seedClient = c.indexOf('await SeedClientAsync(context, tenantIds, ct);') const save = c.indexOf('await _extensions.SaveChangesAsync(ct); // the next entity resolves this one\'s ids') const seedContact = c.indexOf('await SeedContactAsync(context, tenantIds, ct);') expect(seedClient).toBeGreaterThan(0) expect(save).toBeGreaterThan(seedClient) expect(seedContact).toBeGreaterThan(save) expect(c).toContain('var clientId = await _extensions.Set().Where(x => x.Nom == "Direction Marketing" && x.TenantId == tenantId)') expect(c).toContain('{Loaded} ligne(s) chargée(s), {Skipped} sautée(s)') }) it('a GLOBAL target (Portée none) cited from a tenant entity is looked up WITHOUT a tenant clause', () => { const c = providerOf(baseSpec({ testData: [{ entity: 'Client', keyField: 'Nom', rows: [{ Nom: 'A', CantonId: { ref: 'REF/GEO/Canton', entity: 'Canton', keyField: 'Code', key: 'VD', tenantMode: 'none' } }] }] }))!.content expect(c).toContain('var cantonId = await _extensions.Set().Where(x => x.Code == "VD").Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);') expect(c).toContain('x.Nom == "A" && x.TenantId == tenantId') }) it('tenantMode none seeds one global set and resolves without the tenant clause', () => { const c = providerOf(baseSpec({ testData: [{ entity: 'Canton', keyField: 'Code', tenantMode: 'none', rows: [{ Code: 'VD', Label: 'Vaud' }] }] }))!.content expect(c).toContain('private async Task SeedCantonAsync(ICoreDbContext context, CancellationToken ct)') expect(c).not.toContain('context.Tenants') expect(c).toContain('global::Client.Domain.Entities.Canton.Create(code: "VD", label: "Vaud")') }) }) describe('scaffold-seed / testData — anti-regression and validation', () => { it('testData absent → byte-identical output and DI patches (default [] is the historical shape)', () => { // The test-users manifest carries a wall-clock `generatedAt` — neutralised, it is not the subject. const stable = (files: ReturnType) => files.map(f => ({ ...f, content: f.content.replace(/"generatedAt": "[^"]+"/, '"generatedAt": ""') })) const without = stable(generate(ScaffoldSeedInputSchema.parse(baseSpec()))) const explicit = stable(generate(ScaffoldSeedInputSchema.parse(baseSpec({ testData: [], testDataRank: 0 })))) expect(explicit).toEqual(without) expect(without.some(f => f.path.includes('TestData'))).toBe(false) expect(seedDiPatches(ScaffoldSeedInputSchema.parse(baseSpec())).some(p => p.line.includes('TestData'))).toBe(false) }) it('refuses a row without its key, a duplicate key, a cycle, a wrong order and an unsupported Core target', () => { expect(validate(baseSpec({ testData: [{ entity: 'Client', keyField: 'Nom', rows: [{ Statut: 'Actif' }] }] })).errors.some(e => e.includes('missing its natural key'))).toBe(true) expect(validate(baseSpec({ testData: [{ entity: 'Client', keyField: 'Nom', rows: [{ Nom: 'A' }, { Nom: 'A' }] }] })).errors.some(e => e.includes('twice'))).toBe(true) const cyc = validate(baseSpec({ testData: [ { entity: 'Client', keyField: 'Nom', rows: [{ Nom: 'A', ContactId: { ref: 'X/Y/Contact', entity: 'Contact', keyField: 'Email', key: 'a@b' } }] }, { entity: 'Contact', keyField: 'Email', rows: [{ Email: 'a@b', ClientId: { ref: 'X/Y/Client', entity: 'Client', keyField: 'Nom', key: 'A' } }] }, ] })) expect(cyc.errors.some(e => e.includes('dependency cycle'))).toBe(true) const wrongOrder = validate(baseSpec({ testData: [ { entity: 'Contact', keyField: 'Email', rows: [{ Email: 'a@b', ClientId: { ref: 'X/Y/Client', entity: 'Client', keyField: 'Nom', key: 'A' } }] }, { entity: 'Client', keyField: 'Nom', rows: [{ Nom: 'A' }] }, ] })) expect(wrongOrder.errors.some(e => e.includes('listed before it'))).toBe(true) const core = validate(baseSpec({ testData: [{ entity: 'Client', keyField: 'Nom', rows: [{ Nom: 'A', GroupId: { core: 'Group', by: 'Name', value: 'G' } }] }] })) expect(core.errors.some(e => e.includes('cites Core Group'))).toBe(true) }) it('says an actor without a seeded test user, a citation this spec does not seed, and an untyped string column', () => { const r = validate(baseSpec({ testUsers: [], testData: [{ entity: 'Client', keyField: 'Nom', rows: [{ Nom: 'A', ResponsableId: COMMERCIAL, TypeClientId: TYPE_CLIENT, Statut: 'Actif' }] }] })) expect(r.valid).toBe(true) expect(r.warnings.some(w => w.includes('seeds no test user for that role') && w.includes('commercial.test@smartstack.local'))).toBe(true) expect(r.warnings.some(w => w.includes('which this spec does not seed'))).toBe(true) expect(r.warnings.some(w => w.includes('Statut carry string values with no declared type'))).toBe(true) }) it('a foreign citation is said ONCE per (entity → target), not once per cell', () => { const r = validate(baseSpec({ testData: [{ entity: 'Client', keyField: 'Nom', rows: CLIENT_ROWS }] })) const foreign = r.warnings.filter(w => w.includes('CLIENT/CONFIGURATION/TypeClient')) expect(foreign).toHaveLength(1) expect(foreign[0]).toContain('testData[Client].TypeClientId cites') }) it('a typed enum cell whose value is not a C# identifier is said (« En attente » cannot be a member)', () => { const r = validate(baseSpec({ testData: [{ entity: 'Client', keyField: 'Nom', types: { Statut: 'ClientStatut' }, rows: [{ Nom: 'A', Statut: 'En attente' }, { Nom: 'B', Statut: 'Actif' }] }] })) expect(r.valid).toBe(true) const w = r.warnings.find(x => x.includes('not a C# identifier')) expect(w).toContain('Statut « En attente »') expect(w).not.toContain('Actif') }) })