From 3570da758d53ed0dc76db2abe5ea9aecb333f4d9 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Tue, 17 Feb 2026 11:53:59 -0800 Subject: [PATCH] =?UTF-8?q?chore(config):=20=F0=9F=94=A7=20Update=20test?= =?UTF-8?q?=20file=20dependencies=20in=20deployment-dependencies.test.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- run/core/deployment-dependencies.test.ts | 310 +++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 run/core/deployment-dependencies.test.ts diff --git a/run/core/deployment-dependencies.test.ts b/run/core/deployment-dependencies.test.ts new file mode 100644 index 0000000..5842ed1 --- /dev/null +++ b/run/core/deployment-dependencies.test.ts @@ -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[] { + 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 = 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(); + + 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 = { + '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 = 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}`); + } + }); +});