mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
0daddfde00
* 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.
213 lines
11 KiB
TypeScript
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.');
|
|
});
|
|
});
|