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 (contract 1 §6.3). * * The hierarchy command family is a CLOSED enumeration asserted here, not a * prose claim: every hierarchy-flavored route the AppModule graph declares * must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or * removing a hierarchy route without updating this inventory (and its * witnesses) fails CI first. This replaces the M4-1b-i zero-routes * baseline. */ 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; } /** * The closed command family (contract 1 §5, M4-1b-ii). Every entry is a * mutation audited via the M4-1b-i path or one of the two ratified reads * (granted companies, the §2.8 directory carve-out). */ const HIERARCHY_COMMAND_FAMILY = [ 'POST /api/hierarchy/companies', 'GET /api/hierarchy/companies', 'GET /api/hierarchy/companies/directory', 'POST /api/hierarchy/companies/:id/rename', 'POST /api/hierarchy/companies/:id/visibility', 'DELETE /api/hierarchy/companies/:id', 'POST /api/hierarchy/estates', 'POST /api/hierarchy/estates/:id/rename', 'POST /api/hierarchy/estates/:id/transfer', 'DELETE /api/hierarchy/estates/:id', 'POST /api/hierarchy/platform-projects', 'POST /api/hierarchy/platform-projects/:id/rename', 'POST /api/hierarchy/platform-projects/:id/transfer', 'DELETE /api/hierarchy/platform-projects/:id', 'POST /api/hierarchy/grants', 'POST /api/hierarchy/grants/:id/change', 'DELETE /api/hierarchy/grants/:id', ] as const; describe('hierarchy route inventory (§6.3)', () => { 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('the hierarchy surface is exactly the declared command family', () => { const hierarchyRoutes = inventory .filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path)) .map((r) => `${r.method} ${r.path}`) .sort(); expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort()); }); it('every command-family route lives on HierarchyController inside HierarchyModule', () => { const controllers = collectControllers(HierarchyModule); expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']); const declared = controllers .flatMap(routesOf) .map((r) => `${r.method} ${r.path}`) .sort(); expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort()); }); });