/** * Coverage for the tri-state stack_update_status accessors on the real * DatabaseService (against a temp DB, so the migrated schema with check_status / * last_error is exercised exactly as in production): * - upsertStackUpdateStatus persists hasUpdate + check_status + last_error * - getStackUpdateDetail returns the rich per-stack shape * - getStackUpdateStatus stays the boolean map (fleet contract) * - recordStackCheckFailure preserves a prior has_update while marking failed */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; let tmpDir: string; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); }); afterAll(() => cleanupTestDb(tmpDir)); function db() { return DatabaseService.getInstance(); } beforeEach(() => { const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db; raw.prepare('DELETE FROM stack_update_status').run(); }); const NODE = 1; describe('stack_update_status tri-state accessors', () => { it('persists and reads back check_status + last_error via getStackUpdateDetail', () => { db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null); db().upsertStackUpdateStatus(NODE, 'api', false, 2000, 'partial', 'Registry unreachable for ghcr.io/acme/api:v1'); const detail = db().getStackUpdateDetail(NODE); expect(detail.web).toEqual({ hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1000 }); expect(detail.api).toEqual({ hasUpdate: false, checkStatus: 'partial', lastError: 'Registry unreachable for ghcr.io/acme/api:v1', checkedAt: 2000 }); }); it('defaults check_status to ok when omitted', () => { db().upsertStackUpdateStatus(NODE, 'web', true, 1000); expect(db().getStackUpdateDetail(NODE).web.checkStatus).toBe('ok'); }); it('keeps getStackUpdateStatus a raw boolean map ignoring check_status', () => { db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null); db().upsertStackUpdateStatus(NODE, 'api', false, 1000, 'failed', 'boom'); db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half'); expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false, sticky: true }); }); it('projects confirmed updates only via getConfirmedStackUpdateStatus', () => { db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null); db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half'); db().upsertStackUpdateStatus(NODE, 'failed', true, 1000, 'failed', 'boom'); db().upsertStackUpdateStatus(NODE, 'clean', false, 1000, 'ok', null); expect(db().getConfirmedStackUpdateStatus(NODE)).toEqual({ web: true, sticky: false, failed: false, clean: false, }); }); it('clearStackUpdateStatus returns deleted row count and removes services_json', () => { db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'partial', 'half', [ { service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null }, ]); expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(1); expect(db().getStackUpdateDetail(NODE).web).toBeUndefined(); expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(0); }); it('clearAllStackUpdateStatus deletes only the given node rows', () => { const other = NODE + 1; db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null); db().upsertStackUpdateStatus(NODE, 'api', true, 1000, 'ok', null); db().upsertStackUpdateStatus(other, 'web', true, 1000, 'ok', null); expect(db().clearAllStackUpdateStatus(NODE)).toBe(2); expect(db().getStackUpdateDetail(NODE)).toEqual({}); expect(db().getStackUpdateDetail(other).web).toBeDefined(); }); it('getNodeUpdateSummary counts only confirmed updates', () => { db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null); db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half'); const summary = db().getNodeUpdateSummary().find((r) => r.node_id === NODE); expect(summary?.stacks_with_updates).toBe(1); }); it('recordStackCheckFailure preserves a prior has_update while marking failed', () => { // A stack with a confirmed update, then a scan where every image errored. db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null); db().recordStackCheckFailure(NODE, 'web', 'Registry unreachable for registry-1.docker.io/library/nginx:latest', 3000); const detail = db().getStackUpdateDetail(NODE).web; expect(detail.hasUpdate).toBe(true); // not erased by the failed check expect(detail.checkStatus).toBe('failed'); expect(detail.lastError).toContain('Registry unreachable'); expect(detail.checkedAt).toBe(3000); }); it('recordStackCheckFailure on a first-ever check inserts has_update 0 + failed', () => { db().recordStackCheckFailure(NODE, 'fresh', 'auth failed', 4000); const detail = db().getStackUpdateDetail(NODE).fresh; expect(detail).toEqual({ hasUpdate: false, checkStatus: 'failed', lastError: 'auth failed', checkedAt: 4000 }); }); it('scopes detail rows to the node', () => { db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null); db().upsertStackUpdateStatus(2, 'web', false, 1000, 'failed', 'boom'); expect(Object.keys(db().getStackUpdateDetail(NODE))).toEqual(['web']); expect(db().getStackUpdateDetail(NODE).web.hasUpdate).toBe(true); expect(db().getStackUpdateDetail(2).web.checkStatus).toBe('failed'); }); }); describe('stack_update_status services_json (per-service reduction)', () => { const SERVICES = [ { service: 'web', image: 'web:latest', hasUpdate: true, checkStatus: 'ok' as const, lastError: null }, { service: 'worker', image: 'worker:latest', hasUpdate: false, checkStatus: 'ok' as const, lastError: null }, ]; it('persists and returns a per-service breakdown via getStackUpdateDetail', () => { db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null, SERVICES, 3); const detail = db().getStackUpdateDetail(NODE).stackA; expect(detail.services).toEqual(SERVICES); }); it('omits the services field entirely for a stack with no persisted per-service data', () => { db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null); const detail = db().getStackUpdateDetail(NODE).stackA; expect(detail.services).toBeUndefined(); expect('services' in detail).toBe(false); }); it('leaves a prior services_json untouched when a later write omits services (COALESCE)', () => { db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null, SERVICES, 1); // A legacy-path caller (or the fallback tally) writes without a services array. db().upsertStackUpdateStatus(NODE, 'stackA', true, 2000, 'ok', null); expect(db().getStackUpdateDetail(NODE).stackA.services).toEqual(SERVICES); }); it('getStackServicesJson returns [] for a stack with no persisted row', () => { expect(db().getStackServicesJson(NODE, 'missing')).toEqual([]); }); it('getStackServicesJson returns [] for a stack row with no services_json', () => { db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null); expect(db().getStackServicesJson(NODE, 'stackA')).toEqual([]); }); it('getStackServicesJson round-trips a persisted per-service breakdown', () => { db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null, SERVICES, 5); expect(db().getStackServicesJson(NODE, 'stackA')).toEqual(SERVICES); }); it('recordStackCheckFailure persists a per-service breakdown alongside the failure', () => { db().recordStackCheckFailure(NODE, 'stackA', 'Registry unreachable', 4000, SERVICES, 2); const detail = db().getStackUpdateDetail(NODE).stackA; expect(detail.checkStatus).toBe('failed'); expect(detail.services).toEqual(SERVICES); }); it('treats corrupt services_json as an empty list rather than throwing (empty model invariant)', () => { db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null); const raw = (db() as unknown as { db: { prepare: (s: string) => { run: (...args: unknown[]) => void } } }).db; raw.prepare('UPDATE stack_update_status SET services_json = ? WHERE node_id = ? AND stack_name = ?') .run('{not valid json', NODE, 'stackA'); expect(db().getStackServicesJson(NODE, 'stackA')).toEqual([]); const detail = db().getStackUpdateDetail(NODE).stackA; expect(detail.services).toBeUndefined(); expect(detail.hasUpdate).toBe(true); // aggregate columns are unaffected by corrupt services_json }); it('treats a services_json shape-version mismatch as an empty list', () => { db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null); const raw = (db() as unknown as { db: { prepare: (s: string) => { run: (...args: unknown[]) => void } } }).db; raw.prepare('UPDATE stack_update_status SET services_json = ? WHERE node_id = ? AND stack_name = ?') .run(JSON.stringify({ version: 999, generation: 1, services: SERVICES }), NODE, 'stackA'); expect(db().getStackServicesJson(NODE, 'stackA')).toEqual([]); }); it('filters out malformed entries within an otherwise valid services_json array', () => { db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null); const raw = (db() as unknown as { db: { prepare: (s: string) => { run: (...args: unknown[]) => void } } }).db; const malformed = [...SERVICES, { service: 'broken' /* missing required fields */ }]; raw.prepare('UPDATE stack_update_status SET services_json = ? WHERE node_id = ? AND stack_name = ?') .run(JSON.stringify({ version: 1, generation: 1, services: malformed }), NODE, 'stackA'); expect(db().getStackServicesJson(NODE, 'stackA')).toEqual(SERVICES); }); });