platform-tooling/run/core/deployment-dependencies.test.ts
2026-03-02 21:06:53 -08:00

241 lines
9 KiB
TypeScript

/**
* Deployment dependency graph integration tests
*
* Loads REAL deployment YAML files and validates that:
* 1. All declared dependencies resolve to existing services
* 2. No circular dependencies exist
* 3. No port conflicts exist
* 4. Cross-service HTTP dependencies detected in source are declared in YAML
* 5. Transitive dependency chains are complete
*
* This catches the class of bug where a backend adds an HTTP call to
* another service but forgets to declare it in the deployment YAML,
* causing startup ordering failures in `./run dev`.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { join, resolve } from 'node:path';
import { buildDeploymentRegistry } from '@lilith/service-registry';
import type { ServiceRegistry } from '@lilith/service-registry';
// Imported from package source dist until @lilith/service-registry >= 1.4.0 is published.
// Once published, replace with: import { validateRegistry } from '@lilith/service-registry/validation';
const validationPath = resolve(import.meta.dirname, '..', '..', '..', '..', '..', '..', '@packages/@ts/@service/service-registry/dist/validation.js');
const { validateRegistry } = await import(validationPath);
import { scanServiceDependencies } from './source-dependency-scanner';
// ---------------------------------------------------------------------------
// Setup: load real registry from deployment YAMLs
// ---------------------------------------------------------------------------
const PROJECT_ROOT = resolve(import.meta.dirname, '../../..');
const REGISTRY_PATHS = {
deploymentsPath: join(PROJECT_ROOT, 'deployments/@domains'),
sharedServicesPath: join(PROJECT_ROOT, 'deployments/shared-services'),
};
let registry: ServiceRegistry;
beforeAll(() => {
registry = buildDeploymentRegistry(REGISTRY_PATHS);
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Resolve transitive dependencies for a service (depth-first, cycle-safe).
*/
function getTransitiveDeps(serviceId: string, visited = new Set<string>()): string[] {
if (visited.has(serviceId)) return [];
visited.add(serviceId);
const service = registry.services.get(serviceId);
if (!service) return [];
const result: string[] = [];
for (const dep of service.dependencies ?? []) {
result.push(dep);
result.push(...getTransitiveDeps(dep, visited));
}
return result;
}
// ---------------------------------------------------------------------------
// Tests: Registry graph validation (via validateRegistry)
// ---------------------------------------------------------------------------
describe('deployment registry graph validation', () => {
/**
* Known pre-existing missing deps that need separate resolution.
* Each entry should have a comment explaining why it's here.
*/
const KNOWN_MISSING: string[] = [
// platform-api references its own infrastructure services not yet in deployment YAMLs
'platform-api.postgresql',
'platform-api.redis',
];
it('should load services from all deployment YAMLs', () => {
expect(registry.services.size).toBeGreaterThan(0);
});
it('should pass graph validation (no missing deps, no cycles, no port conflicts)', () => {
const result = validateRegistry(registry, {
knownMissing: KNOWN_MISSING,
// Port conflicts are expected: domain deployments (trustedmeet, spoiledbabes, etc.)
// share the same ports because they use the same feature code and never run simultaneously.
checkPortConflicts: false,
});
if (!result.valid) {
const details = result.errors
.map((e: { type: string; message: string }) => ` [${e.type}] ${e.message}`)
.join('\n');
expect.fail(`Registry validation failed:\n${details}`);
}
});
});
// ---------------------------------------------------------------------------
// Tests: Specific dependency chains
// ---------------------------------------------------------------------------
describe('messaging dependency chain', () => {
it('should declare profile.api as a dependency', () => {
const messaging = registry.services.get('messaging.api');
expect(messaging).toBeDefined();
expect(messaging!.dependencies).toContain('profile.api');
});
it('should transitively resolve attributes.api through profile.api', () => {
const deps = getTransitiveDeps('messaging.api');
expect(deps).toContain('profile.api');
expect(deps).toContain('attributes.api');
});
it('should resolve all databases before their APIs', () => {
const deps = getTransitiveDeps('messaging.api');
// Each API's database should appear in the transitive chain
expect(deps).toContain('messaging.postgresql');
expect(deps).toContain('messaging.redis');
expect(deps).toContain('profile.postgresql');
expect(deps).toContain('attributes.postgresql');
});
it('should not depend on sso.api (JWT validation is local)', () => {
const messaging = registry.services.get('messaging.api');
expect(messaging).toBeDefined();
expect(messaging!.dependencies).not.toContain('sso.api');
});
});
describe('profile dependency chain', () => {
it('should declare attributes.api as a dependency', () => {
const profile = registry.services.get('profile.api');
expect(profile).toBeDefined();
expect(profile!.dependencies).toContain('attributes.api');
});
it('should transitively resolve attributes.postgresql', () => {
const deps = getTransitiveDeps('profile.api');
expect(deps).toContain('attributes.api');
expect(deps).toContain('attributes.postgresql');
});
it('should not depend on sso.api (JWT validation is local)', () => {
const profile = registry.services.get('profile.api');
expect(profile).toBeDefined();
expect(profile!.dependencies).not.toContain('sso.api');
});
});
describe('attributes dependency chain', () => {
it('should only depend on its own postgresql', () => {
const attrs = registry.services.get('attributes.api');
expect(attrs).toBeDefined();
expect(attrs!.dependencies).toEqual(['attributes.postgresql']);
});
});
// ---------------------------------------------------------------------------
// Tests: Source-to-YAML consistency (detect undeclared HTTP dependencies)
// ---------------------------------------------------------------------------
describe('source-to-YAML dependency consistency', () => {
/**
* Known pre-existing undeclared dependencies with documented reasons.
* Format: "sourceServiceId->targetServiceId"
*
* Each entry MUST have a comment explaining why it's here and what
* the resolution path is (add to YAML, architectural decision, etc.)
*/
const KNOWN_UNDECLARED: ReadonlySet<string> = new Set([
// Shared services calling domain-scoped marketplace deployments.
// These can't be declared as YAML deps because the target ID varies per deployment
// (trustedmeet.www.api, spoiledbabes.www.api, etc.). The calls are for optional
// quota checks and profile sync — the services degrade gracefully without them.
'messaging.api->trustedmeet.www.api',
'profile.api->trustedmeet.www.api',
]);
/**
* For each API service with an entrypoint, scan its source for cross-service
* HTTP calls and verify they're declared in the YAML dependencies.
*/
it('should declare all cross-service dependencies detected in source code', () => {
const undeclared: Array<{
service: string;
calledService: string;
pattern: string;
file: string;
line: number;
}> = [];
for (const [id, service] of registry.services) {
if (service.type !== 'api' || !service.entrypoint) continue;
const detected = scanServiceDependencies(id, service.entrypoint, PROJECT_ROOT);
const declaredDeps = new Set(getTransitiveDeps(id));
for (const dep of service.dependencies ?? []) {
declaredDeps.add(dep);
}
for (const dep of detected) {
const dedupeKey = `${dep.sourceServiceId}->${dep.targetServiceId}`;
if (KNOWN_UNDECLARED.has(dedupeKey)) continue;
// Skip targets that don't exist in the registry (may be external or deprecated)
if (!registry.services.has(dep.targetServiceId)) continue;
if (!declaredDeps.has(dep.targetServiceId)) {
undeclared.push({
service: id,
calledService: dep.targetServiceId,
pattern: dep.pattern,
file: dep.filePath.replace(PROJECT_ROOT + '/', ''),
line: dep.line,
});
}
}
}
if (undeclared.length > 0) {
const details = undeclared
.map(
(u) =>
` ${u.service} calls ${u.calledService}\n` +
` detected via: ${u.pattern} at ${u.file}:${u.line}\n` +
` fix: add "${u.calledService}" to dependencies in services.yaml`,
)
.join('\n');
expect.fail(
`Undeclared cross-service dependencies found:\n${details}\n\n` +
`If intentional (optional/degraded-graceful), add to KNOWN_UNDECLARED with a reason.`,
);
}
});
});