Files
sencho/backend/src/__tests__/dependency-map-aggregation.test.ts
T
Anso af4083175c feat(fleet): add read-only dependency map tab (#1324)
* feat(fleet): add read-only dependency map tab

Add a fleet-wide Dependencies tab to Fleet view that maps how stacks,
services, networks, volumes, and ports relate, with flags for missing
dependencies, port conflicts, orphaned resources, and cross-stack shared
resources. Read-only; filterable by stack, node, and flag; collapsed by
default with a list-view fallback at scale.

The graph is derived at request time from Docker and compose metadata, so
no new table or persisted state is introduced. A per-node graph endpoint
feeds a hub aggregation endpoint that fans out across the fleet and
degrades gracefully, surfacing unreachable or unparseable nodes inline
while the rest of the map still renders.

* fix(fleet): harden dependency map flag detection and remote merge

Address review findings on the dependency map:
- Port-conflict detection now does pairwise host-scope overlap, so an
  unrelated bind on the same port and protocol but a different specific host
  IP is no longer flagged, and the flag lands on the exact scoped port node.
- A running service's depends_on target is only considered satisfied when it
  is actually running, so a crashed (exited) dependency is surfaced while a
  deliberately stopped stack stays quiet.
- Declared external networks and volumes are reported missing when they do
  not exist on the host instead of being assumed present.
- The hub deep-validates each remote node-graph payload before merging, so a
  reachable-but-malformed remote degrades to a single node error rather than
  failing the whole fleet map, and the validation failure is logged.
- Searching or filtering on a network, volume, or port now also reveals the
  services that claim it and their stacks.
2026-06-06 11:18:36 -04:00

92 lines
4.5 KiB
TypeScript

/**
* Unit tests for the fleet dependency-map merge: per-node id namespacing,
* authoritative node attribution, and graceful partial-failure handling.
*/
import { describe, it, expect } from 'vitest';
import { mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult, type LocalDependencyGraph } from '../services/DependencyGraphService';
function localGraph(nodeId: number, nodeName: string, stack: string): LocalDependencyGraph {
return {
nodeId,
nodeName,
nodes: [
{ id: 'host', kind: 'host', label: nodeName, nodeId, nodeName, stack: null, flags: [] },
{ id: `stack:${stack}`, kind: 'stack', label: stack, nodeId, nodeName, stack, flags: [] },
],
edges: [{ id: `e:host-stack:${stack}`, source: 'host', target: `stack:${stack}`, kind: 'stack-node' }],
flags: [{ kind: 'orphan', nodeId, nodeName, subjects: [`stack:${stack}`], detail: 'x' }],
parseErrors: [{ stack, error: 'bad' }],
};
}
const ok = (nodeId: number, nodeName: string, stack: string): FleetNodeGraphResult => ({
nodeId, nodeName, status: 'ok', graph: localGraph(nodeId, nodeName, stack), error: null,
});
const err = (nodeId: number, nodeName: string, error: string): FleetNodeGraphResult => ({
nodeId, nodeName, status: 'error', graph: null, error,
});
describe('mergeFleetGraph', () => {
it('namespaces ids by node so identical stack names stay distinct', () => {
const merged = mergeFleetGraph([ok(1, 'hub', 'web'), ok(2, 'edge', 'web')]);
const stackNodes = merged.nodes.filter((n) => n.kind === 'stack');
expect(stackNodes.map((n) => n.id).sort()).toEqual(['n1:stack:web', 'n2:stack:web']);
expect(merged.edges.map((e) => e.id).sort()).toEqual(['n1:e:host-stack:web', 'n2:e:host-stack:web']);
});
it('re-stamps host label and attribution from the hub registry', () => {
const graph = localGraph(1, 'wrong-self-name', 'web');
const merged = mergeFleetGraph([{ nodeId: 7, nodeName: 'authoritative', status: 'ok', graph, error: null }]);
const host = merged.nodes.find((n) => n.kind === 'host');
expect(host?.label).toBe('authoritative');
expect(host?.nodeName).toBe('authoritative');
expect(host?.id).toBe('n7:host');
});
it('namespaces flag subjects and carries parse errors with node attribution', () => {
const merged = mergeFleetGraph([ok(3, 'edge', 'api')]);
expect(merged.flags[0].subjects).toEqual(['n3:stack:api']);
expect(merged.parseErrors).toEqual([{ nodeId: 3, nodeName: 'edge', stack: 'api', error: 'bad' }]);
});
it('degrades a failed node to nodeErrors while keeping healthy nodes', () => {
const merged = mergeFleetGraph([ok(1, 'hub', 'web'), err(2, 'edge', 'unreachable')]);
expect(merged.nodeErrors).toEqual([{ nodeId: 2, nodeName: 'edge', error: 'unreachable' }]);
expect(merged.nodes.some((n) => n.id === 'n1:stack:web')).toBe(true);
expect(merged.nodes.some((n) => n.id.startsWith('n2:'))).toBe(false);
});
it('returns an empty graph with full nodeErrors when every node fails', () => {
const merged = mergeFleetGraph([err(1, 'hub', 'down'), err(2, 'edge', 'down')]);
expect(merged.nodes).toHaveLength(0);
expect(merged.edges).toHaveLength(0);
expect(merged.nodeErrors).toHaveLength(2);
});
});
describe('isLocalDependencyGraph', () => {
it('accepts a well-formed graph and one with parseErrors absent', () => {
expect(isLocalDependencyGraph({ nodes: [{ id: 'host', flags: [] }], edges: [{ id: 'e', source: 'a', target: 'b' }], flags: [{ subjects: ['a'] }], parseErrors: [{ stack: 's', error: 'x' }] })).toBe(true);
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [] })).toBe(true);
});
it('rejects null and non-array core fields', () => {
expect(isLocalDependencyGraph(null)).toBe(false);
expect(isLocalDependencyGraph({ nodes: {}, edges: [], flags: [] })).toBe(false);
});
it('rejects a node missing its flags array', () => {
expect(isLocalDependencyGraph({ nodes: [{ id: 'x' }], edges: [], flags: [] })).toBe(false);
});
it('rejects flags whose subjects are missing or non-string (would corrupt merge)', () => {
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [{}] })).toBe(false);
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [{ subjects: [1] }] })).toBe(false);
});
it('rejects malformed parseErrors elements (would throw in merge)', () => {
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [], parseErrors: [null] })).toBe(false);
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [], parseErrors: ['oops'] })).toBe(false);
});
});