241 lines
9 KiB
TypeScript
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.`,
|
|
);
|
|
}
|
|
});
|
|
});
|