import { RequestMethod, type Type } from '@nestjs/common'; import { describe, expect, it } from 'vitest'; import { AppModule } from '../app.module.js'; import { HierarchyModule } from '../hierarchy/hierarchy.module.js'; /** * Hierarchy route-inventory baseline (contract 1 §6.3(a)). * * M4-1b-i ships the audit event + outbox machinery with NO mutation routes: * the hierarchy command family (controllers + DTOs) lands in M4-1b-ii once * contract 2 merges. This witness enumerates every route the AppModule graph * declares and pins that baseline, so a hierarchy route appearing before its * command-family witnesses exist fails here first. When M4-1b-ii lands, this * baseline is replaced by an exact inventory of the command family. */ interface RouteEntry { method: string; path: string; controller: string; } /** Module-metadata entry: a module class or a DynamicModule-shaped object. */ type ModuleEntry = | Type | { module: Type; imports?: unknown[]; controllers?: Type[] }; function collectControllers(root: ModuleEntry): Type[] { const visited = new Set(); const controllers: Type[] = []; const walk = (entry: ModuleEntry | undefined | null): void => { if (!entry || visited.has(entry)) return; visited.add(entry); const moduleClass = typeof entry === 'function' ? entry : entry.module; // Entries with no resolvable class (forwardRef wrappers, async dynamic // modules) carry no decorator metadata to read here. if (typeof moduleClass !== 'function') return; if (visited.has(moduleClass) && typeof entry !== 'function') return; visited.add(moduleClass); // 'controllers' / 'imports' are the metadata keys the @Module decorator writes. const declared = (Reflect.getMetadata('controllers', moduleClass) ?? []) as Type[]; controllers.push(...declared); if (typeof entry !== 'function' && entry.controllers) controllers.push(...entry.controllers); const imports = [ ...((Reflect.getMetadata('imports', moduleClass) ?? []) as ModuleEntry[]), ...(typeof entry !== 'function' ? ((entry.imports ?? []) as ModuleEntry[]) : []), ]; for (const imported of imports) walk(imported); }; walk(root); return controllers; } function routesOf(controller: Type): RouteEntry[] { // 'path' on the class is the @Controller prefix; 'path'/'method' on a // handler are written by the @Get/@Post/... route decorators. const base = (Reflect.getMetadata('path', controller) ?? '') as string | string[]; const bases = Array.isArray(base) ? base : [base]; const routes: RouteEntry[] = []; const prototype = controller.prototype as Record; for (const name of Object.getOwnPropertyNames(prototype)) { if (name === 'constructor') continue; const handler = Object.getOwnPropertyDescriptor(prototype, name)?.value; if (typeof handler !== 'function') continue; const method = Reflect.getMetadata('method', handler) as number | undefined; if (method === undefined) continue; const sub = (Reflect.getMetadata('path', handler) ?? '/') as string; for (const prefix of bases) { const path = `/${prefix}/${sub}`.replace(/\/+/g, '/').replace(/(.)\/$/, '$1'); routes.push({ method: RequestMethod[method] ?? String(method), path, controller: controller.name, }); } } return routes; } describe('hierarchy route-inventory baseline (§6.3(a))', () => { const inventory = collectControllers(AppModule).flatMap(routesOf); it('control: the enumeration sees the known route surface', () => { const paths = inventory.map((r) => `${r.method} ${r.path}`); expect(paths).toContain('GET /health'); expect(paths).toContain('POST /api/workspaces'); expect(paths).toContain('GET /api/teams'); expect(inventory.length).toBeGreaterThan(20); }); it('declares zero hierarchy mutation routes before M4-1b-ii', () => { const hierarchyRoutes = inventory.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path), ); expect( hierarchyRoutes, 'a hierarchy route landed without replacing the §6.3(a) baseline with a command-family inventory', ).toEqual([]); }); it('HierarchyModule itself declares no controllers', () => { expect((Reflect.getMetadata('controllers', HierarchyModule) ?? []) as unknown[]).toEqual([]); const hierarchyControllers = collectControllers(HierarchyModule); expect(hierarchyControllers).toEqual([]); }); });