chore(config): 🔧 Update test file dependencies in deployment-dependencies.test.ts
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5617e10b90
commit
3570da758d
1 changed files with 310 additions and 0 deletions
310
run/core/deployment-dependencies.test.ts
Normal file
310
run/core/deployment-dependencies.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue