import { describe, it, expect } from 'vitest'; import { renderEntity, patchExtensionSearch, ensureUsing } from '../generate.js'; import { buildExtensionSearchSpec } from '../build-spec.js'; import { validate } from '../validate.js'; import { BEGIN_MARKER, END_MARKER, type ExtensionSearchSpec, type SearchEntity } from '../types.js'; function entity(over: Partial = {}): SearchEntity { return { entityName: 'Test.Domain.Entities.Task', categoryKey: 'tasks', label: 'Tâches', icon: 'ListTodo', permission: 'todo.taches.liste.read', route: '/todo/taches/liste/{id}', tenantScoped: true, order: 100, ...over, }; } function spec(over: Partial = {}): ExtensionSearchSpec { return { appCode: 'Test', contextType: 'ExtensionsDbContext', projectPath: '/tmp/test', entities: [entity()], ...over, }; } const DI_WITH_MARKERS = `using Microsoft.Extensions.DependencyInjection; using SmartStack.Infrastructure.Services.Search; using Test.Infrastructure.Persistence; namespace Test.Infrastructure; public static class DependencyInjection { public static IServiceCollection AddTestInfrastructure(this IServiceCollection services, IConfiguration configuration) { services.AddSmartStackExtensionDbContext(configuration); services.AddExtensionSearch(search => { // <<< EXTENSION-SEARCH-DI BEGIN >>> // search.Entity("myentities", "My entities", "Box") // .RequirePermission("{app}.{module}.{section}.read"); // <<< EXTENSION-SEARCH-DI END >>> }); return services; } } `; const DI_NO_MARKERS = `using Microsoft.Extensions.DependencyInjection; using Test.Infrastructure.Persistence; namespace Test.Infrastructure; public static class DependencyInjection { public static IServiceCollection AddTestInfrastructure(this IServiceCollection services, IConfiguration configuration) { services.AddSmartStackExtensionDbContext(configuration); return services; } } `; describe('scaffold-extension-search / renderEntity', () => { it('renders the full fluent chain with tenant scope', () => { const out = renderEntity(entity()); expect(out).toContain('search.Entity("tasks", "Tâches", "ListTodo")'); expect(out).toContain('.RequirePermission("todo.taches.liste.read")'); expect(out).toContain('.RouteTo(e => $"/todo/taches/liste/{e.Id}")'); // {id} → {e.Id} expect(out).toContain('.TenantScoped()'); expect(out.trimEnd().endsWith(';')).toBe(true); }); it('emits the row scope mirroring the list handler', () => { const out = renderEntity(entity({ rowScope: { bypassPermission: 'todo.taches.liste.assign', ownerProperty: 'AssignedToUserId' } })); expect(out).toContain('.RestrictTo(scope => scope.Has("todo.taches.liste.assign")'); expect(out).toContain(': e => e.AssignedToUserId == scope.UserId)'); }); it('omits TenantScoped and RestrictTo when not requested', () => { const out = renderEntity(entity({ tenantScoped: false })); expect(out).not.toContain('.TenantScoped()'); expect(out).not.toContain('.RestrictTo('); }); it('emits .Order only when non-default', () => { expect(renderEntity(entity({ order: 100 }))).not.toContain('.Order('); expect(renderEntity(entity({ order: 50 }))).toContain('.Order(50)'); }); }); describe('scaffold-extension-search / patch (markers present)', () => { it('replaces the placeholder content between the markers', () => { const next = patchExtensionSearch(DI_WITH_MARKERS, spec()); expect(next).not.toBeNull(); expect(next!).toContain('search.Entity("tasks"'); expect(next!).not.toContain('MyEntity'); // placeholder gone expect(next!).toContain(BEGIN_MARKER); expect(next!).toContain(END_MARKER); // exactly one marker pair expect(next!.match(new RegExp(BEGIN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))!.length).toBe(1); }); it('is idempotent — a second identical patch is a no-op', () => { const once = patchExtensionSearch(DI_WITH_MARKERS, spec())!; const twice = patchExtensionSearch(once, spec()); expect(twice).toBeNull(); }); }); describe('scaffold-extension-search / patch (no markers)', () => { it('inserts the full wrapper before return services; and adds the using', () => { const next = patchExtensionSearch(DI_NO_MARKERS, spec()); expect(next).not.toBeNull(); expect(next!).toContain('using SmartStack.Infrastructure.Services.Search;'); expect(next!).toContain('services.AddExtensionSearch(search =>'); expect(next!).toContain('search.Entity("tasks"'); // wrapper sits before the return expect(next!.indexOf('AddExtensionSearch')).toBeLessThan(next!.indexOf('return services;')); }); it('ensureUsing does not duplicate an existing using', () => { const once = ensureUsing(DI_NO_MARKERS, 'SmartStack.Infrastructure.Services.Search'); const twice = ensureUsing(once, 'SmartStack.Infrastructure.Services.Search'); expect(twice.match(/using SmartStack\.Infrastructure\.Services\.Search;/g)!.length).toBe(1); }); }); describe('scaffold-extension-search / build-spec', () => { const sections = [ { code: 'liste', moduleCode: 'taches', appCode: 'todo', label: 'Tâches', icon: 'ListTodo', route: '/todo/taches/liste' }, { code: 'categories', moduleCode: 'parametres', appCode: 'todo', label: 'Catégories', icon: 'Tag' }, ]; it('maps list screens to entities with derived permission, route and category key', () => { const { spec: built, warnings } = buildExtensionSearchSpec({ appCode: 'Test', projectPath: '/tmp', entityNamespace: 'Test.Domain.Entities', sections, permissions: ['todo.taches.liste.read', 'todo.parametres.categories.read'], listScreens: [ { entityName: 'Task', sectionCode: 'liste', rowScope: { bypassPermission: 'todo.taches.liste.assign', ownerProperty: 'AssignedToUserId' } }, { entityName: 'Category', sectionCode: 'categories' }, ], }); expect(warnings).toEqual([]); expect(built.entities).toHaveLength(2); const task = built.entities[0]; expect(task.entityName).toBe('Test.Domain.Entities.Task'); // fully-qualified expect(task.categoryKey).toBe('tasks'); expect(task.permission).toBe('todo.taches.liste.read'); expect(task.route).toBe('/todo/taches/liste/{id}'); expect(task.rowScope?.ownerProperty).toBe('AssignedToUserId'); const cat = built.entities[1]; expect(cat.categoryKey).toBe('categories'); expect(cat.route).toBe('/todo/parametres/categories/{id}'); // route defaulted from app/module/section }); it('warns when a section .read permission is missing', () => { const { warnings } = buildExtensionSearchSpec({ appCode: 'Test', projectPath: '/tmp', entityNamespace: 'Test.Domain.Entities', sections, permissions: [], // none seeded listScreens: [{ entityName: 'Task', sectionCode: 'liste' }], }); expect(warnings.some((w) => w.includes('todo.taches.liste.read'))).toBe(true); }); it('skips a list screen whose section is unknown', () => { const { spec: built, warnings } = buildExtensionSearchSpec({ appCode: 'Test', projectPath: '/tmp', sections, permissions: [], listScreens: [{ entityName: 'Ghost', sectionCode: 'nope' }], }); expect(built.entities).toHaveLength(0); expect(warnings.some((w) => w.includes('unknown section'))).toBe(true); }); }); describe('scaffold-extension-search / validate', () => { it('applies defaults (contextType, tenantScoped, order)', () => { const r = validate({ appCode: 'Test', projectPath: '/tmp', entities: [{ entityName: 'X.Task', categoryKey: 'tasks', label: 'T', icon: 'I', permission: 'a.b.c.read', route: '/a/b/c/{id}' }] }); expect(r.valid).toBe(true); expect(r.data!.contextType).toBe('ExtensionsDbContext'); expect(r.data!.entities[0].tenantScoped).toBe(true); expect(r.data!.entities[0].order).toBe(100); }); it('rejects a route without an {id} placeholder and a non-read permission', () => { const r = validate({ appCode: 'Test', projectPath: '/tmp', entities: [{ entityName: 'X', categoryKey: 'x', label: 'X', icon: 'I', permission: 'a.b.c.create', route: '/a/b/c' }] }); expect(r.valid).toBe(false); expect(r.errors.join(' ')).toMatch(/route|permission/i); }); });