/** * Unit tests for the ยง5 per-service reduction pure functions: * reduceServiceStatus, aggregateServiceCheckStatus, and * buildAvailabilityNotifyMessage. These operate on plain data (an * imageUpdateMap and a prior per-service snapshot), so they are tested * directly without mocking DatabaseService, Docker, or Compose. */ import { describe, it, expect } from 'vitest'; import { reduceServiceStatus, aggregateServiceCheckStatus, buildAvailabilityNotifyMessage, type ImageCheckResult, } from '../services/ImageUpdateService'; import type { StackServiceStatus } from '../services/DatabaseService'; function ok(hasUpdate: boolean): ImageCheckResult { return { hasUpdate }; } function errored(message: string): ImageCheckResult { return { hasUpdate: false, error: message }; } function notCheckable(): ImageCheckResult { return { hasUpdate: false, notCheckable: true }; } describe('reduceServiceStatus', () => { it('marks a service not_checkable when it has no checkable ref (STATUS-NOT-CHECKABLE-3)', () => { const map = new Map([['app:latest', notCheckable()]]); const { status, confirmedUpdateThisRun } = reduceServiceStatus('app', 'app:latest', [], map, undefined); expect(status).toEqual({ service: 'app', image: 'app:latest', hasUpdate: false, checkStatus: 'not_checkable', lastError: null }); expect(confirmedUpdateThisRun).toBe(false); }); it('marks a service not_checkable when it declares no image and has no running container', () => { const map = new Map(); const { status, confirmedUpdateThisRun } = reduceServiceStatus('worker', null, [], map, undefined); expect(status).toEqual({ service: 'worker', image: null, hasUpdate: false, checkStatus: 'not_checkable', lastError: null }); expect(confirmedUpdateThisRun).toBe(false); }); it('reports ok + hasUpdate=true when every checkable ref succeeds and one confirms an update', () => { const map = new Map([['app:latest', ok(true)]]); const { status, confirmedUpdateThisRun } = reduceServiceStatus('app', 'app:latest', [], map, undefined); expect(status).toEqual({ service: 'app', image: 'app:latest', hasUpdate: true, checkStatus: 'ok', lastError: null }); expect(confirmedUpdateThisRun).toBe(true); }); it('reports ok + hasUpdate=false when every checkable ref succeeds with no update', () => { const map = new Map([['app:latest', ok(false)]]); const { status, confirmedUpdateThisRun } = reduceServiceStatus('app', 'app:latest', [], map, undefined); expect(status.checkStatus).toBe('ok'); expect(status.hasUpdate).toBe(false); expect(confirmedUpdateThisRun).toBe(false); }); it('STATUS-CONFIRMATION-PARTIAL-5: one newly outdated success + one error -> partial, hasUpdate=true, confirmedUpdateThisRun=true', () => { // Two refs on the same declared service (declared image + a divergent runtime image). const map = new Map([ ['app:latest', ok(true)], ['app:1.2.3', errored('Registry unreachable for app:1.2.3')], ]); const { status, confirmedUpdateThisRun } = reduceServiceStatus('app', 'app:latest', ['app:1.2.3'], map, undefined); expect(status.checkStatus).toBe('partial'); expect(status.hasUpdate).toBe(true); expect(status.lastError).toBe('Registry unreachable for app:1.2.3'); expect(confirmedUpdateThisRun).toBe(true); }); it('partial with only a preserved prior (no successful outdated check this run) -> confirmedUpdateThisRun=false', () => { const map = new Map([ ['app:latest', ok(false)], // succeeds, but reports no update ['app:1.2.3', errored('timeout')], ]); const prior: StackServiceStatus = { service: 'app', image: 'app:latest', hasUpdate: true, checkStatus: 'ok', lastError: null }; const { status, confirmedUpdateThisRun } = reduceServiceStatus('app', 'app:latest', ['app:1.2.3'], map, prior); expect(status.checkStatus).toBe('partial'); expect(status.hasUpdate).toBe(true); // preserved from prior, not newly confirmed expect(confirmedUpdateThisRun).toBe(false); }); it('all checkable refs erroring -> failed, preserves prior hasUpdate, confirmedUpdateThisRun=false', () => { const map = new Map([['app:latest', errored('Registry unreachable')]]); const prior: StackServiceStatus = { service: 'app', image: 'app:latest', hasUpdate: true, checkStatus: 'ok', lastError: null }; const { status, confirmedUpdateThisRun } = reduceServiceStatus('app', 'app:latest', [], map, prior); expect(status).toEqual({ service: 'app', image: 'app:latest', hasUpdate: true, checkStatus: 'failed', lastError: 'Registry unreachable' }); expect(confirmedUpdateThisRun).toBe(false); }); it('all checkable refs erroring with no prior row -> failed, hasUpdate=false', () => { const map = new Map([['app:latest', errored('boom')]]); const { status } = reduceServiceStatus('app', 'app:latest', [], map, undefined); expect(status.hasUpdate).toBe(false); expect(status.checkStatus).toBe('failed'); }); it('a shared image gets the same check result on every declared service that references it', () => { const map = new Map([['shared:latest', ok(true)]]); const web = reduceServiceStatus('web', 'shared:latest', [], map, undefined); const worker = reduceServiceStatus('worker', 'shared:latest', [], map, undefined); expect(web.status.hasUpdate).toBe(true); expect(worker.status.hasUpdate).toBe(true); expect(web.status.checkStatus).toBe(worker.status.checkStatus); }); it('records sorted, deduplicated runtimeImages only when a runtime container was observed', () => { const map = new Map([['app:latest', ok(false)], ['app:1.0', ok(false)]]); const withRuntime = reduceServiceStatus('app', 'app:latest', ['app:1.0', 'app:1.0'], map, undefined); expect(withRuntime.status.runtimeImages).toEqual(['app:1.0']); const noRuntime = reduceServiceStatus('app', 'app:latest', [], map, undefined); expect(noRuntime.status.runtimeImages).toBeUndefined(); }); }); describe('aggregateServiceCheckStatus', () => { const svc = (checkStatus: StackServiceStatus['checkStatus'], hasUpdate = false): StackServiceStatus => ({ service: `svc-${checkStatus}-${hasUpdate}`, image: null, hasUpdate, checkStatus, lastError: null, }); it('is ok for an empty services array (empty effective model)', () => { expect(aggregateServiceCheckStatus([])).toBe('ok'); }); it('is ok when every service is not_checkable (nothing checkable)', () => { expect(aggregateServiceCheckStatus([svc('not_checkable'), svc('not_checkable')])).toBe('ok'); }); it('is ok when every checkable service succeeded', () => { expect(aggregateServiceCheckStatus([svc('ok'), svc('ok'), svc('not_checkable')])).toBe('ok'); }); it('is failed when every checkable service failed, even with a preserved hasUpdate=true', () => { expect(aggregateServiceCheckStatus([svc('failed', true), svc('failed', true)])).toBe('failed'); }); it('is partial when checkable services mix ok and failed', () => { expect(aggregateServiceCheckStatus([svc('ok'), svc('failed')])).toBe('partial'); }); it('is partial when a checkable service is itself partial', () => { expect(aggregateServiceCheckStatus([svc('ok'), svc('partial')])).toBe('partial'); }); it('is not failed when only some (not all) checkable services failed', () => { expect(aggregateServiceCheckStatus([svc('ok'), svc('failed'), svc('not_checkable')])).not.toBe('failed'); }); }); describe('aggregate invariant: has_update === services.some(hasUpdate)', () => { it('holds for an empty services array', () => { const services: StackServiceStatus[] = []; expect(services.some(s => s.hasUpdate)).toBe(false); }); it('holds when every checkable service failed but one preserves hasUpdate=true', () => { const map = new Map([['app:latest', errored('unreachable')]]); const prior: StackServiceStatus = { service: 'app', image: 'app:latest', hasUpdate: true, checkStatus: 'ok', lastError: null }; const { status } = reduceServiceStatus('app', 'app:latest', [], map, prior); const services = [status]; expect(services.some(s => s.hasUpdate)).toBe(true); expect(aggregateServiceCheckStatus(services)).toBe('failed'); }); it('holds when one service confirms an update and a sibling service fails outright', () => { const map = new Map([ ['web:latest', ok(true)], ['worker:latest', errored('timeout')], ]); const web = reduceServiceStatus('web', 'web:latest', [], map, undefined); const worker = reduceServiceStatus('worker', 'worker:latest', [], map, undefined); const services = [web.status, worker.status]; // web confirmed an update; worker's sole ref errored, so worker alone is 'failed'. expect(services.some(s => s.hasUpdate)).toBe(true); expect(worker.status.checkStatus).toBe('failed'); // At the stack level, one ok service and one failed service is a mix, not a unanimous failure. expect(aggregateServiceCheckStatus(services)).toBe('partial'); }); }); describe('buildAvailabilityNotifyMessage', () => { it('uses the generic line for a single-service (or model-unavailable) stack', () => { expect(buildAvailabilityNotifyMessage('stackA', [])).toBe('Stack "stackA" has image updates available.'); const single: StackServiceStatus[] = [{ service: 'app', image: 'app:latest', hasUpdate: true, checkStatus: 'ok', lastError: null }]; expect(buildAvailabilityNotifyMessage('stackA', single)).toBe('Stack "stackA" has image updates available.'); }); it('names sorted services with hasUpdate for a multi-service stack', () => { const services: StackServiceStatus[] = [ { service: 'worker', image: 'worker:latest', hasUpdate: true, checkStatus: 'ok', lastError: null }, { service: 'api', image: 'api:latest', hasUpdate: true, checkStatus: 'ok', lastError: null }, { service: 'db', image: 'db:latest', hasUpdate: false, checkStatus: 'ok', lastError: null }, ]; expect(buildAvailabilityNotifyMessage('stackA', services)).toBe('Stack "stackA" has image updates available for services: api, worker.'); }); it('falls back to the generic line for a multi-service stack with no service currently showing hasUpdate', () => { const services: StackServiceStatus[] = [ { service: 'api', image: 'api:latest', hasUpdate: false, checkStatus: 'ok', lastError: null }, { service: 'db', image: 'db:latest', hasUpdate: false, checkStatus: 'ok', lastError: null }, ]; expect(buildAvailabilityNotifyMessage('stackA', services)).toBe('Stack "stackA" has image updates available.'); }); it('uses singular wording for exactly one named service', () => { const services: StackServiceStatus[] = [ { service: 'api', image: 'api:latest', hasUpdate: true, checkStatus: 'ok', lastError: null }, { service: 'db', image: 'db:latest', hasUpdate: false, checkStatus: 'ok', lastError: null }, ]; expect(buildAvailabilityNotifyMessage('stackA', services)).toBe('Stack "stackA" has image updates available for service: api.'); }); });