Files
sencho/backend/src/__tests__/image-update-service-reduction.test.ts
T
Anso 63213c0960 feat: add service-scoped Compose update and restore (#1648)
* feat: add service-scoped Compose update and restore

Allow updating or rebuilding one declared Compose service on multi-service
stacks without recreating siblings, with recovery snapshots, health-gate
observation, and prune holds for rollback images. Full-stack update paths
and single-service UX stay unchanged.

* fix: sanitize service-scoped update log messages for CodeQL

* fix: address service-scoped update audit findings B-01 through B-07

* fix: complete service-scoped update audit metadata and surfaces

* test: wrap Updates readiness tests for deploy-feedback context

* fix: keep service recovery reachable without Deploy Progress

Make failed service-gate recovery discoverable when Deploy Progress is
disabled or dismissed, suppress stale image-scan notification side
effects, normalize ComposeService line endings, and add focused
regression coverage.

* fix: resurface ContainersHealth density and expand on multi-service stacks

Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
2026-07-19 02:42:29 -04:00

213 lines
11 KiB
TypeScript

/**
* 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<string, ImageCheckResult>();
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.');
});
});