Files
sencho/backend/src/__tests__/image-update-service-reduction.test.ts
T
Anso 0daddfde00 fix: reconcile sticky update indicators with Anatomy preview (#1698)
* fix: reconcile sticky update indicators with Anatomy preview

Sidebar, Updates filter, and Fleet treated retained partial/failed
scanner has_update as confirmed. Keep raw state for retention/notifications,
project confirmed-only to APIs, show distinct incomplete indicators, and
clear sticky rows only after an authoritative-negative preview.

Closes #1685

* test: align sidebar truncate E2E with failed-over-retained precedence

Purple update indicators are confirmed-only; hasUpdate with a failed
check correctly shows the failed trailing icon.

* fix: clear confirmed update rows on authoritative-negative preview

Address audit SF-1/SF-2/SF-3: observation-watermark clears for older
ok+has_update rows (DB + memory gens), Fleet checkability parity with
backend not_checkable, and Updates chip confirmed-only regressions.

* fix: tombstone equal-generation writers on preview clear

Advance the per-stack write generation when clearing at the observation
watermark so a scanner reserved before preview cannot recreate the row
after an authoritative-negative reconcile.

* fix: clear sticky updates with digest and tag preview parity

Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear.

* test: set digestUpdate on auto-update checkImage mocks

Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path.

* fix: clear unused lint errors on sticky update branch

Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green.

* fix: use inflight holder for CacheService prefer-const

Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
2026-07-25 15:42:19 -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, checkStatus: 'ok', digestUpdate: hasUpdate, tagUpdate: false };
}
function errored(message: string): ImageCheckResult {
return { hasUpdate: false, checkStatus: 'failed', error: message };
}
function notCheckable(): ImageCheckResult {
return { hasUpdate: false, checkStatus: 'not_checkable', 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.');
});
});