/** * di-patch.test.ts — scaffold-business LANDS its DI registrations (§ the * "every endpoint answers 500" class): one AddScoped per entity unioned into * the Infrastructure host's BUSINESS-SERVICES-DI block, plus the client * Application assembly scan (MediatR + validators). The patching primitives * live in lib/di-markers (their own suite); this one pins WHAT the generator * derives — hosts, markers, fully-qualified types — and the end-to-end merge. */ import { describe, it, expect } from 'vitest' import { diPatches, BUSINESS_DI_BEGIN, BUSINESS_DI_END, CLIENT_APP_DI_BEGIN, CLIENT_APP_DI_END, } from '../generate.js' import { mergeLineIntoMarkerBlock, spliceDiMarkerBlock } from '../../../../../../lib/di-markers.js' import { ScaffoldBusinessInputSchema, type ScaffoldBusinessInput } from '../types.js' function spec(overrides: Partial = {}): ScaffoldBusinessInput { return ScaffoldBusinessInputSchema.parse({ name: 'Vehicle', pluralName: 'Vehicles', module: 'parc', appCode: 'DemoFlotte', applicationCode: 'flotte', namespace: 'DemoFlotte', fields: [{ name: 'Vin', type: 'string', required: true, isKey: false }], businessRules: [], customActions: [], projectPath: '/tmp/project', ...overrides, }) } const INFRA_HOST = `public static class DependencyInjection { public static IServiceCollection AddDemoFlotteInfrastructure(this IServiceCollection services) { // <<< BUSINESS-SERVICES-DI BEGIN >>> // services.AddScoped(); // <<< BUSINESS-SERVICES-DI END >>> return services; } } ` describe('scaffold-business / diPatches', () => { it('derives the AddScoped line with fully-qualified global:: types (no using injection)', () => { const [svc] = diPatches(spec()) expect(svc.line).toBe( 'services.AddScoped();', ) expect(svc.hostCandidates[0]).toBe('src/DemoFlotte.Infrastructure/DependencyInjection.cs') expect(svc.begin).toBe(BUSINESS_DI_BEGIN) expect(svc.end).toBe(BUSINESS_DI_END) }) it('the client Application assembly scan is the second patch (whole block, skip guard)', () => { const [, app] = diPatches(spec()) expect(app.hostCandidates).toEqual(['src/DemoFlotte.Application/DependencyInjection.cs']) expect(app.block).toContain('AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly))') expect(app.block).toContain('AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly)') expect(app.block).toContain(CLIENT_APP_DI_BEGIN) expect(app.block).toContain(CLIENT_APP_DI_END) expect(app.skipIfPresent).toBe('RegisterServicesFromAssembl') }) it('end-to-end: two entities land as two unioned lines, re-run is a no-op', () => { const [vehicle] = diPatches(spec()) const [driver] = diPatches(spec({ name: 'Driver', pluralName: 'Drivers', module: 'conducteurs' })) const one = mergeLineIntoMarkerBlock(INFRA_HOST, vehicle.begin, vehicle.end, vehicle.line!) const two = mergeLineIntoMarkerBlock((one as { source: string }).source, driver.begin, driver.end, driver.line!) const src = (two as { source: string }).source expect(src).toContain('IVehicleService') expect(src).toContain('IDriverService') expect(src.indexOf('IVehicleService')).toBeLessThan(src.indexOf('IDriverService')) const again = mergeLineIntoMarkerBlock(src, vehicle.begin, vehicle.end, vehicle.line!) expect(again).toEqual({ status: 'unchanged', reason: 'line-in-block' }) }) it('a pre-marker host (legacy project) still lands the line before `return services;`', () => { const legacy = INFRA_HOST.replace(/ {8}\/\/ <<< BUSINESS-SERVICES-DI[\s\S]*?END >>>\n/, '') expect(legacy).not.toContain(BUSINESS_DI_BEGIN) const [vehicle] = diPatches(spec()) const r = mergeLineIntoMarkerBlock(legacy, vehicle.begin, vehicle.end, vehicle.line!) expect(r.status).toBe('patched') const src = (r as { source: string }).source expect(src.indexOf('IVehicleService')).toBeLessThan(src.indexOf('return services;')) }) it('the Application block splices idempotently through spliceDiMarkerBlock', () => { const APP_HOST = `public static class DependencyInjection { public static IServiceCollection AddDemoFlotteApplication(this IServiceCollection services) { // <<< CLIENT-APPLICATION-ASSEMBLY-DI BEGIN >>> services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); // <<< CLIENT-APPLICATION-ASSEMBLY-DI END >>> return services; } } ` const [, app] = diPatches(spec()) // Already canonical → no rewrite. expect(spliceDiMarkerBlock(APP_HOST, app.begin, app.end, app.block!)).toBeNull() }) }) describe('scaffold-business / diPatches — TimeProvider (clock-referencing rules)', () => { it('a clock-referencing rule lands the TimeProvider.System singleton in the same marker block', () => { const patches = diPatches(spec({ businessRules: [{ id: 'BR-007', description: 'Minimum age', expression: 'RuleFor(x => x.BirthDate).LessThan(DateTime.UtcNow)', validExamples: [], invalidExamples: [], }] })) const tp = patches.find(p => p.line?.includes('TimeProvider.System')) expect(tp).toBeDefined() expect(tp!.begin).toBe(BUSINESS_DI_BEGIN) expect(tp!.line).toBe('services.AddSingleton(global::System.TimeProvider.System);') // A project that registered its own TimeProvider (any lifetime, any // instance) keeps it — the patch backs off entirely. expect(tp!.skipIfPresent).toBe('TimeProvider.System') }) it('no clock reference in any rule → no TimeProvider registration (bounded blast radius)', () => { expect(diPatches(spec()).some(p => p.line?.includes('TimeProvider'))).toBe(false) }) })