import { describe, expect, it } from 'vitest' import { ensureUsing, modelBuilderParam, patchDbContext, patchDi, policyFqType, renderPolicies, renderPolicy, visibilityExpression, } from '../generate.js' import { validate } from '../validate.js' import { DATA_SCOPES_NAMESPACE, DI_BEGIN_MARKER, DI_END_MARKER, FILTERS_BEGIN_MARKER, FILTERS_END_MARKER, type DataScopeEntity, type DataScopeSpec, } from '../types.js' function entity(over: Partial = {}): DataScopeEntity { return { entityName: 'Opportunite', applicationCode: 'crm', module: 'pipeline', mode: 'own', readPermission: 'crm.pipeline.opportunites.read', ownerProperty: 'OwnerUserId', assignedProperty: 'AssignedToUserId', ...over, } } function spec(over: Partial = {}): DataScopeSpec { return { appCode: 'Test', contextType: 'ExtensionsDbContext', projectPath: '/tmp/project', entities: [entity()], ...over, } } const DBCONTEXT_SOURCE = `using Microsoft.EntityFrameworkCore; using SmartStack.Infrastructure.Persistence.Extensions; namespace Test.Infrastructure.Persistence; public class ExtensionsDbContext : SmartStackExtensionDbContext { public DbSet Opportunites => Set(); protected override void OnExtensionModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema(SchemaConstants.Extensions); modelBuilder.ApplyConfigurationsFromAssembly(typeof(ExtensionsDbContext).Assembly); } } ` const DI_SOURCE = `using Microsoft.Extensions.DependencyInjection; using Test.Infrastructure.Persistence; namespace Test.Infrastructure; public static class DependencyInjection { public static IServiceCollection AddTestInfrastructure(this IServiceCollection services) { services.AddSmartStackExtensionDbContext(configuration); return services; } } ` describe('scaffold-data-scope / policy rendering', () => { it('renders an own policy with the .all bypass derived from the read permission', () => { const file = renderPolicy(spec(), entity()) expect(file.path).toBe('src/Test.Application/Crm/Pipeline/Authorization/OpportuniteScopePolicy.cs') expect(file.content).toContain(`using ${DATA_SCOPES_NAMESPACE};`) expect(file.content).toContain('using Test.Domain.Entities;') expect(file.content).toContain('namespace Test.Application.Crm.Pipeline.Authorization;') expect(file.content).toContain('public sealed class OpportuniteScopePolicy : DataScopePolicy') expect(file.content).toContain('public static readonly OpportuniteScopePolicy Instance = new();') expect(file.content).toContain('public override string ScopeAllPermission => "crm.pipeline.opportunites.read.all";') expect(file.content).toContain('(e, userId) => e.OwnerUserId == userId;') }) it('renders assigned and own-assigned visibility expressions', () => { expect(visibilityExpression(entity({ mode: 'assigned' }))).toBe('e.AssignedToUserId == userId') expect(visibilityExpression(entity({ mode: 'own-assigned' }))) .toBe('e.OwnerUserId == userId || e.AssignedToUserId == userId') }) it('honors custom ownership property names', () => { const file = renderPolicy(spec(), entity({ mode: 'own', ownerProperty: 'CreatedByUserId' })) expect(file.content).toContain('(e, userId) => e.CreatedByUserId == userId;') }) it('never emits RequireDataScope (Core-only guard, broken on extension entities)', () => { for (const file of renderPolicies(spec({ entities: [entity(), entity({ entityName: 'Devis', mode: 'own-assigned' })] }))) { expect(file.content).not.toContain('RequireDataScope') } }) }) describe('scaffold-data-scope / DbContext patch', () => { it('inserts the marker block at the end of OnExtensionModelCreating when markers are absent', () => { const next = patchDbContext(DBCONTEXT_SOURCE, spec())! expect(next).toContain(FILTERS_BEGIN_MARKER) expect(next).toContain(FILTERS_END_MARKER) expect(next).toContain('ApplyDataScopeFilter(modelBuilder, Test.Application.Crm.Pipeline.Authorization.OpportuniteScopePolicy.Instance);') // Block lands INSIDE the method (before its closing brace), after the existing statements. const blockIdx = next.indexOf(FILTERS_BEGIN_MARKER) expect(blockIdx).toBeGreaterThan(next.indexOf('ApplyConfigurationsFromAssembly')) expect(blockIdx).toBeLessThan(next.lastIndexOf('}')) }) it('uses the ACTUAL ModelBuilder parameter name of the client context', () => { const renamed = DBCONTEXT_SOURCE.replace(/modelBuilder/g, 'mb') expect(modelBuilderParam(renamed)).toBe('mb') const next = patchDbContext(renamed, spec())! expect(next).toContain('ApplyDataScopeFilter(mb, Test.Application.Crm.Pipeline.Authorization.OpportuniteScopePolicy.Instance);') }) it('is idempotent: re-running on the patched source returns null (no change)', () => { const once = patchDbContext(DBCONTEXT_SOURCE, spec())! expect(patchDbContext(once, spec())).toBeNull() }) it('full-spec marker replacement drops stale lines when an entity leaves the spec', () => { const two = spec({ entities: [entity(), entity({ entityName: 'Devis' })] }) const withTwo = patchDbContext(DBCONTEXT_SOURCE, two)! expect(withTwo).toContain('DevisScopePolicy.Instance') const backToOne = patchDbContext(withTwo, spec())! expect(backToOne).not.toContain('DevisScopePolicy') expect(backToOne).toContain('OpportuniteScopePolicy.Instance') }) it('returns null when the context does not override OnExtensionModelCreating', () => { const noHook = 'public class ExtensionsDbContext : SmartStackExtensionDbContext { }' expect(patchDbContext(noHook, spec())).toBeNull() }) }) describe('scaffold-data-scope / DI patch', () => { it('inserts the block before `return services;` and ensures the IDataScopePolicy using', () => { const next = patchDi(DI_SOURCE, spec())! expect(next).toContain(`using ${DATA_SCOPES_NAMESPACE};`) expect(next).toContain(DI_BEGIN_MARKER) expect(next).toContain(DI_END_MARKER) expect(next).toContain('services.AddSingleton(Test.Application.Crm.Pipeline.Authorization.OpportuniteScopePolicy.Instance);') expect(next.indexOf(DI_END_MARKER)).toBeLessThan(next.indexOf('return services;')) }) it('is idempotent: re-running on the patched source returns null', () => { const once = patchDi(DI_SOURCE, spec())! expect(patchDi(once, spec())).toBeNull() }) it('full-spec marker replacement keeps the region in sync', () => { const once = patchDi(DI_SOURCE, spec())! const two = patchDi(once, spec({ entities: [entity(), entity({ entityName: 'Devis', module: 'devis' })] }))! expect(two).toContain('DevisScopePolicy.Instance') const backToOne = patchDi(two, spec())! expect(backToOne).not.toContain('DevisScopePolicy') }) it('ensureUsing is a no-op when the using is already present', () => { const src = `using ${DATA_SCOPES_NAMESPACE};\nnamespace X;` expect(ensureUsing(src, DATA_SCOPES_NAMESPACE)).toBe(src) }) }) describe('scaffold-data-scope / validate', () => { it('accepts a minimal valid spec and applies defaults', () => { const r = validate({ appCode: 'Test', projectPath: '/tmp/p', entities: [{ entityName: 'Opportunite', applicationCode: 'crm', module: 'pipeline', mode: 'own', readPermission: 'crm.pipeline.opportunites.read', }], }) expect(r.valid).toBe(true) expect(r.data!.contextType).toBe('ExtensionsDbContext') expect(r.data!.entities[0].ownerProperty).toBe('OwnerUserId') }) it('rejects a readPermission not ending in .read', () => { const r = validate({ appCode: 'Test', projectPath: '/tmp/p', entities: [{ entityName: 'X', applicationCode: 'crm', module: 'pipeline', mode: 'own', readPermission: 'crm.pipeline.opportunites.read.all' }], }) expect(r.valid).toBe(false) }) it('rejects duplicate entities and own-assigned with a single column', () => { const dup = validate({ appCode: 'Test', projectPath: '/tmp/p', entities: [ { entityName: 'X', applicationCode: 'crm', module: 'm', mode: 'own', readPermission: 'a.b.c.read' }, { entityName: 'X', applicationCode: 'crm', module: 'm', mode: 'own', readPermission: 'a.b.d.read' }, ], }) expect(dup.valid).toBe(false) const same = validate({ appCode: 'Test', projectPath: '/tmp/p', entities: [{ entityName: 'X', applicationCode: 'crm', module: 'm', mode: 'own-assigned', readPermission: 'a.b.c.read', ownerProperty: 'OwnerUserId', assignedProperty: 'OwnerUserId' }], }) expect(same.valid).toBe(false) }) it('warns on a 3-segment (non app-qualified) read permission', () => { const r = validate({ appCode: 'Test', projectPath: '/tmp/p', entities: [{ entityName: 'X', applicationCode: 'crm', module: 'm', mode: 'own', readPermission: 'pipeline.opportunites.read' }], }) expect(r.valid).toBe(true) expect(r.warnings.some(w => w.includes('fewer than 4 segments'))).toBe(true) }) }) describe('scaffold-data-scope / policyFqType', () => { it('derives the fully-qualified policy type from app/module segments', () => { expect(policyFqType(spec(), entity({ module: 'suivi-heures' }))) .toBe('Test.Application.Crm.SuiviHeures.Authorization.OpportuniteScopePolicy') }) })