chore(config): 🔧 Update test file dependencies in deployment-dependencies.test.ts

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Quinn Ftw 2026-02-17 11:53:59 -08:00
parent 5617e10b90
commit 3570da758d

View file

@ -0,0 +1,310 @@
/**
* Deployment dependency graph integration tests
*
* Loads REAL deployment YAML files and validates that:
* 1. All declared dependencies resolve to existing services
* 2. Cross-service HTTP dependencies are declared in YAML
* 3. No circular dependencies exist
* 4. 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 { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { buildDeploymentRegistry } from '@lilith/service-registry';
import type { ServiceRegistry } from '@lilith/service-registry';
// ---------------------------------------------------------------------------
// 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;
}
/**
* Scan a backend source directory for HTTP calls to other services.
* Returns env var prefixes detected (e.g., 'profile', 'attributes').
*
* Detects patterns:
* - SERVICE_API_HOST / SERVICE_API_PORT / SERVICE_API_URL env vars
* - getServiceUrl('service-name') or getApiUrl('service-name')
*/
function scanForHttpDependencies(entrypoint: string): string[] {
const srcDir = join(PROJECT_ROOT, entrypoint, 'src');
if (!existsSync(srcDir)) return [];
const deps: string[] = [];
const files = findTsFiles(srcDir);
for (const file of files) {
// Skip test files
if (file.includes('.spec.') || file.includes('.test.') || file.includes('/test/')) continue;
const content = readFileSync(file, 'utf-8');
// Pattern: ENV var like PROFILE_API_HOST, ATTRIBUTES_API_URL
const envMatches = content.matchAll(/(\w+)_API_(?:HOST|PORT|URL)/g);
for (const match of envMatches) {
const serviceName = match[1].toLowerCase().replace(/_/g, '-');
deps.push(serviceName);
}
// Pattern: getServiceUrl('service-name') or getApiUrl('service-name')
const urlMatches = content.matchAll(/get(?:Service|Api)Url\(['"]([^'"]+)['"]\)/g);
for (const match of urlMatches) {
deps.push(match[1]);
}
}
return [...new Set(deps)];
}
function findTsFiles(dir: string): string[] {
const results: string[] = [];
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'dist') {
results.push(...findTsFiles(fullPath));
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
results.push(fullPath);
}
}
} catch {
// Directory may not exist
}
return results;
}
// ---------------------------------------------------------------------------
// Tests: Registry integrity
// ---------------------------------------------------------------------------
describe('deployment registry integrity', () => {
it('should load services from all deployment YAMLs', () => {
expect(registry.services.size).toBeGreaterThan(0);
});
it('should have all declared dependencies resolve to existing services', () => {
// Known pre-existing: platform-api.api references services not yet in registry
const KNOWN_MISSING: ReadonlySet<string> = new Set([
'platform-api.postgresql',
'platform-api.redis',
]);
const missing: Array<{ service: string; dependency: string }> = [];
for (const [id, service] of registry.services) {
for (const dep of service.dependencies ?? []) {
if (!registry.services.has(dep) && !KNOWN_MISSING.has(dep)) {
missing.push({ service: id, dependency: dep });
}
}
}
expect(missing).toEqual([]);
});
it('should have no circular dependencies', () => {
const cycles: string[] = [];
for (const [id] of registry.services) {
const path: string[] = [];
const visited = new Set<string>();
function visit(serviceId: string): boolean {
if (path.includes(serviceId)) {
cycles.push([...path.slice(path.indexOf(serviceId)), serviceId].join(' → '));
return true;
}
if (visited.has(serviceId)) return false;
visited.add(serviceId);
path.push(serviceId);
const service = registry.services.get(serviceId);
for (const dep of service?.dependencies ?? []) {
if (visit(dep)) return true;
}
path.pop();
return false;
}
visit(id);
}
expect(cycles).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// 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: Code-to-YAML consistency (detect undeclared HTTP dependencies)
// ---------------------------------------------------------------------------
describe('code-to-YAML dependency consistency', () => {
/**
* Map from detected env var prefix expected YAML dependency service ID.
* Env var patterns like PROFILE_API_HOST 'profile' prefix profile.api
*/
const ENV_PREFIX_TO_SERVICE: Record<string, string> = {
'attributes': 'attributes.api',
'profile': 'profile.api',
'sso': 'sso.api',
'messaging': 'messaging.api',
'media': 'media.api',
};
/**
* Known pre-existing undeclared dependencies that need separate tickets.
* Format: "caller→callee"
*/
const KNOWN_UNDECLARED: ReadonlySet<string> = new Set([
// messaging and profile call marketplace for quota checks / profile sync
// but marketplace uses domain-scoped IDs (trustedmeet.www.api) not shared service IDs.
// Tracked separately — requires deciding if marketplace should be a shared service.
]);
/**
* For each API service with an entrypoint, scan its source for HTTP calls
* to other services and verify they're declared in the YAML dependencies.
*/
it('should declare all HTTP dependencies detected in source code', () => {
const undeclared: Array<{ service: string; calledService: string; detectedVia: string }> = [];
for (const [id, service] of registry.services) {
if (service.type !== 'api' || !service.entrypoint) continue;
const httpDeps = scanForHttpDependencies(service.entrypoint);
const declaredDeps = new Set(getTransitiveDeps(id));
for (const dep of service.dependencies ?? []) {
declaredDeps.add(dep);
}
for (const httpDep of httpDeps) {
const expectedService = ENV_PREFIX_TO_SERVICE[httpDep];
if (!expectedService) continue;
// Skip self-references
const selfPrefix = id.split('.')[0];
if (httpDep === selfPrefix) continue;
const key = `${id}${expectedService}`;
if (KNOWN_UNDECLARED.has(key)) continue;
if (!declaredDeps.has(expectedService)) {
undeclared.push({
service: id,
calledService: expectedService,
detectedVia: `${httpDep.toUpperCase()}_API_* env var in source`,
});
}
}
}
if (undeclared.length > 0) {
const details = undeclared
.map(u => ` ${u.service} calls ${u.calledService} (detected via ${u.detectedVia}) but doesn't declare it`)
.join('\n');
expect.fail(`Undeclared HTTP dependencies found:\n${details}`);
}
});
});