134 lines
5.4 KiB
TypeScript
134 lines
5.4 KiB
TypeScript
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<unknown>
|
|
| { module: Type<unknown>; imports?: unknown[]; controllers?: Type<unknown>[] };
|
|
|
|
function collectControllers(root: ModuleEntry): Type<unknown>[] {
|
|
const visited = new Set<unknown>();
|
|
const controllers: Type<unknown>[] = [];
|
|
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<unknown>[];
|
|
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<unknown>): 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<string, unknown>;
|
|
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());
|
|
});
|
|
});
|