/** * Regression tests for WorkflowEngine.healthCheckService (DC-042 followup). * * Bug: bundled-workflows.js:310 called `servicesStateManager.getState()` — * a method that doesn't exist on StateManager. Combined with a missing * `await`, this returned a Promise instead of an array, which then short- * circuited via `|| []` to an empty array. The result: every health-check- * on-interval workflow ran successfully with 0 services checked, while * the workflow engine still reported "Action health-check failed: * servicesStateManager.getState is not a function" on the dashboard. * * Fix: call `await servicesStateManager.read()` with a .catch fallback to * an empty array so a corrupt/missing state file doesn't break the * workflow. */ const { WorkflowEngine } = require('../src/recipes/bundled-workflows'); function makeEngine(opts = {}) { const ctx = { servicesStateManager: opts.servicesStateManager || { read: jest.fn().mockResolvedValue([]), }, docker: opts.docker !== undefined ? opts.docker : { client: { getContainer: jest.fn(), }, }, }; const engine = new WorkflowEngine(ctx); // The constructor calls startScheduledWorkflows() which sets setInterval jobs. // Those prevent Jest from exiting cleanly. Clear them after construction. // We only care about healthCheckService behavior here, not scheduling. if (engine.scheduledJobs) { for (const job of engine.scheduledJobs.values()) { clearInterval(job); } engine.scheduledJobs.clear(); } return engine; } describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)', () => { test('uses .read() not the non-existent .getState() — does not throw', async () => { const readMock = jest.fn().mockResolvedValue([]); const engine = makeEngine({ servicesStateManager: { read: readMock }, docker: undefined, // no docker — exercises the falsy branch }); // The original bug: this throws `servicesStateManager.getState is not a function` const result = await engine.healthCheckService('{{serviceId}}'); expect(readMock).toHaveBeenCalledTimes(1); expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] }); }); test('returns checked/healthy counts from read() output (all healthy)', async () => { const docker = { client: { getContainer: jest.fn((id) => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true, Health: { Status: 'healthy' } }, }), })), }, }; const engine = makeEngine({ servicesStateManager: { read: jest.fn().mockResolvedValue([ { id: 'svc-1', containerId: 'c1' }, { id: 'svc-2', containerId: 'c2' }, { id: 'svc-3' }, // no containerId, should be skipped ]), }, docker, }); const result = await engine.healthCheckService('{{serviceId}}'); expect(result.checked).toBe(2); // svc-3 skipped (no containerId) expect(result.healthy).toBe(2); // both containers healthy expect(result.results).toHaveLength(2); expect(result.results[0]).toMatchObject({ service: 'svc-1', healthy: true }); expect(result.results[1]).toMatchObject({ service: 'svc-2', healthy: true }); expect(result.failing).toEqual([]); }); test('throws when any service is unhealthy — surfaces failing service IDs', async () => { const docker = { client: { getContainer: jest.fn((id) => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: id !== 'c2', Health: { Status: id === 'c2' ? 'unhealthy' : 'healthy' } }, }), })), }, }; const engine = makeEngine({ servicesStateManager: { read: jest.fn().mockResolvedValue([ { id: 'svc-1', containerId: 'c1' }, { id: 'svc-2', containerId: 'c2' }, ]), }, docker, }); await expect(engine.healthCheckService('{{serviceId}}')).rejects.toThrow(/svc-2/); await expect(engine.healthCheckService('{{serviceId}}')).rejects.toMatchObject({ failingServices: ['svc-2'], workflowResult: expect.objectContaining({ checked: 2, healthy: 1, failing: ['svc-2'] }), }); }); test('gracefully degrades if read() throws — empty services list, no crash', async () => { const engine = makeEngine({ servicesStateManager: { read: jest.fn().mockRejectedValue(new Error('disk on fire')), }, docker: undefined, }); // Before the fix, this rejected because .read() wasn't called and the // .catch(() => []) fallback didn't exist. Now it should resolve to empty. const result = await engine.healthCheckService('{{serviceId}}'); expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] }); }); test('servicesStateManager absent on ctx → no crash, empty result', async () => { const engine = new WorkflowEngine({ servicesStateManager: null, docker: undefined, }); // Same constructor cleanup if (engine.scheduledJobs) { for (const job of engine.scheduledJobs.values()) clearInterval(job); engine.scheduledJobs.clear(); } const result = await engine.healthCheckService('{{serviceId}}'); expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] }); }); test('single service (non-template serviceId) path still works', async () => { const engine = makeEngine({ docker: { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true } }), })), }, }, }); const result = await engine.healthCheckService('single-svc-id'); expect(result).toEqual({ serviceId: 'single-svc-id', healthy: true }); }); test('single-service check throws when container is unhealthy', async () => { const engine = makeEngine({ docker: { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: false } }), })), }, }, }); await expect(engine.healthCheckService('down-svc')).rejects.toMatchObject({ failingServices: ['down-svc'], }); }); }); /** * DC-044 root-cause fix tests: notify-on-failure gating + template interpolation. * * The original code in executeAction had TWO latent bugs: * 1. notify-on-failure sent unconditionally (its comment said "Only send if * previous action failed" but the code never checked). * 2. healthCheckService returned no serviceId field, so templates like * `Health check failed for {{serviceId}}` never interpolated and stayed * literal in every alert. * * These tests exercise the full executeWorkflow path with a stub workflow * that pairs `health-check` with `notify-on-failure`. */ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure + template)', () => { // Build an engine and call _runActions directly with arbitrary action // sequences. Bypasses BUNDLED_WORKFLOWS lookup so tests are isolated and // don't mutate module state. function makeEngine(opts = {}) { const ctx = { servicesStateManager: opts.servicesStateManager || { read: jest.fn().mockResolvedValue([]) }, docker: opts.docker || { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true } }) })) } }, notification: opts.notification || { send: jest.fn() }, }; const engine = new WorkflowEngine(ctx); if (engine.scheduledJobs) { for (const job of engine.scheduledJobs.values()) clearInterval(job); engine.scheduledJobs.clear(); } return engine; } test('notify-on-failure is a no-op when the previous action succeeded', async () => { const notify = jest.fn(); const engine = makeEngine({ servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-1', containerId: 'c1' }]) }, docker: { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true } }), })) } }, notification: { send: notify }, }); const results = await engine._runActions( [ { type: 'health-check', target: '{{serviceId}}' }, { type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' }, ], { trigger: 'manual' } ); const notifyResult = results.find(r => r.action === 'notify-on-failure'); expect(notifyResult.success).toBe(true); expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' }); expect(notify).not.toHaveBeenCalled(); }); test('notify-on-failure fires and interpolates {{failingServices}} when previous action failed', async () => { const notify = jest.fn(); const engine = makeEngine({ servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-broken', containerId: 'c1' }]) }, docker: { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: false } }), })) } }, notification: { send: notify }, }); const results = await engine._runActions( [ { type: 'health-check', target: '{{serviceId}}' }, { type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' }, ], { trigger: 'manual' } ); const healthResult = results.find(r => r.action === 'health-check'); const notifyResult = results.find(r => r.action === 'notify-on-failure'); expect(healthResult.success).toBe(false); expect(healthResult.failingServices).toEqual(['svc-broken']); expect(notifyResult.success).toBe(true); expect(notify).toHaveBeenCalledTimes(1); // DC-094 notification.send signature: (event, { title, text }, level) const sentMessage = notify.mock.calls[0][1].text; expect(sentMessage).toBe('Health check failed for svc-broken'); expect(sentMessage).not.toContain('{{'); }); test('notify (not notify-on-failure) fires unconditionally — regression guard', async () => { const notify = jest.fn(); const engine = makeEngine({ notification: { send: notify } }); const results = await engine._runActions( [{ type: 'notify', message: 'always sent' }], { trigger: 'manual' } ); expect(notify).toHaveBeenCalledTimes(1); expect(notify.mock.calls[0][1].text).toBe('always sent'); expect(results[0].success).toBe(true); }); test('notify-on-failure as first action is a no-op (no previous result)', async () => { const notify = jest.fn(); const engine = makeEngine({ notification: { send: notify } }); const results = await engine._runActions( [{ type: 'notify-on-failure', message: 'should not fire' }], { trigger: 'manual' } ); const notifyResult = results[0]; expect(notifyResult.success).toBe(true); expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' }); expect(notify).not.toHaveBeenCalled(); }); test('multi-service batch failure: {{failingServices}} interpolates comma-joined list', async () => { const notify = jest.fn(); const engine = makeEngine({ servicesStateManager: { read: jest.fn().mockResolvedValue([ { id: 'svc-ok', containerId: 'c1' }, { id: 'svc-broken-1', containerId: 'c2' }, { id: 'svc-broken-2', containerId: 'c3' }, ]) }, docker: { client: { getContainer: jest.fn((id) => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: id === 'c1', Health: { Status: id === 'c1' ? 'healthy' : 'unhealthy' } }, }), })) } }, notification: { send: notify }, }); const results = await engine._runActions( [ { type: 'health-check', target: '{{serviceId}}' }, { type: 'notify-on-failure', message: 'Failing: {{failingServices}}' }, ], { trigger: 'manual' } ); expect(notify).toHaveBeenCalledTimes(1); const sentMessage = notify.mock.calls[0][1].text; expect(sentMessage).toBe('Failing: svc-broken-1,svc-broken-2'); expect(results.find(r => r.action === 'notify-on-failure').success).toBe(true); }); // B2 regression: hit the actual bundled health-check-on-interval workflow // end-to-end via executeWorkflow. The bundled template uses // {{failingServices}} (DC-044 fix). Earlier it used {{serviceId}} which // never resolved because no per-service ID is in workflow scope. This test // would have failed with the old template. test('executeWorkflow on bundled health-check-on-interval: no literal {{...}} in notification', async () => { const { BUNDLED_WORKFLOWS } = require('../src/recipes/bundled-workflows'); expect(BUNDLED_WORKFLOWS['health-check-on-interval']).toBeDefined(); const notify = jest.fn(); const engine = makeEngine({ servicesStateManager: { read: jest.fn().mockResolvedValue([ { id: 'svc-broken', containerId: 'c1' }, ]) }, docker: { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: false, Health: { Status: 'unhealthy' } }, }), })) } }, notification: { send: notify }, }); const result = await engine.executeWorkflow('health-check-on-interval', { trigger: 'manual' }); // Either the bundled workflow fired notification (with interpolated // message) OR every action resolved — but in NO case may a literal // {{...}} template token leak into notification.send. if (notify.mock.calls.length > 0) { const sentMessage = notify.mock.calls[0][1].text; expect(sentMessage).not.toMatch(/\{\{/); expect(sentMessage).not.toMatch(/\}\}/); // The new bundled template substitutes failingServices — make sure // the actual service ID made it through. expect(sentMessage).toContain('svc-broken'); } // Workflow must always complete (success or failure), never throw. expect(result).toBeDefined(); expect(result.workflowId).toBe('health-check-on-interval'); }); // B3 regression: a running container with Health.Status === 'unhealthy' // must be reported as unhealthy. Previously checkContainerHealth compared // info.State.Health itself (an object) to the string 'unhealthy', which // was always false — so any container with an explicit healthcheck was // always considered healthy. The fix reads info.State.Health.Status. test('checkContainerHealth treats running-but-unhealthy container as unhealthy', async () => { const engine = makeEngine({ docker: { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true, Health: { Status: 'unhealthy' } }, }), })) } }, }); const healthy = await engine.checkContainerHealth('running-but-unhealthy'); expect(healthy).toBe(false); }); test('checkContainerHealth treats running-with-no-healthcheck as healthy', async () => { const engine = makeEngine({ docker: { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true } }), })) } }, }); const healthy = await engine.checkContainerHealth('no-healthcheck'); expect(healthy).toBe(true); }); test('checkContainerHealth treats stopped container as unhealthy', async () => { const engine = makeEngine({ docker: { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: false } }), })) } }, }); const healthy = await engine.checkContainerHealth('stopped'); expect(healthy).toBe(false); }); });