Files
dockhand-upstream/tests/pending-updates-core.test.ts
T

154 lines
5.6 KiB
TypeScript

/**
* Unit tests for pendingRowsToClear (#1311).
*
* A stack redeploy with "Pull images" recreates containers with new ids, orphaning
* the pending_container_updates rows keyed by the OLD id — the dashboard then shows a
* permanent false "update available". We clear a row ONLY when we can prove the
* container now runs the newest local image; every ambiguity keeps the row so a REAL
* pending update is never hidden (the user's core concern when --pull always doesn't
* land the latest: per-service pull failure, build:, pull_policy:never).
*/
import { describe, test, expect } from 'bun:test';
import { pendingRowsToClear, countLivePending, type PendingRow, type LiveContainer } from '../src/lib/server/pending-updates-core';
const STACK = 'shop';
const LATEST = 'sha256:newnewnew';
const OLD = 'sha256:oldoldold';
// tag -> newest local id after --pull always
const latestIdForTag = (tag: string) => (tag === 'nginx:latest' ? LATEST : tag === 'unresolvable:tag' ? null : LATEST);
const row = (name: string, id: string, img = 'nginx:latest'): PendingRow => ({ containerId: id, containerName: name, currentImage: img });
const live = (name: string, imageId: string, project?: string): LiveContainer => ({ name, imageId, project });
describe('pendingRowsToClear', () => {
test('1. CLEARS a stack container recreated on the newest image', () => {
const out = pendingRowsToClear(
[row('shop-web-1', 'old-id')],
[live('shop-web-1', LATEST, STACK)],
latestIdForTag, STACK
);
expect(out).toEqual(['old-id']);
});
test('2. KEEPS a stack container still on the OLD image (pull failed / build: / never)', () => {
const out = pendingRowsToClear(
[row('shop-web-1', 'old-id')],
[live('shop-web-1', OLD, STACK)], // recreated but still old image
latestIdForTag, STACK
);
expect(out).toEqual([]); // a real pending update must NOT be hidden
});
test('3. KEEPS when the tag cannot be resolved to a local id (fail-safe)', () => {
const out = pendingRowsToClear(
[row('shop-web-1', 'old-id', 'unresolvable:tag')],
[live('shop-web-1', LATEST, STACK)],
latestIdForTag, STACK
);
expect(out).toEqual([]);
});
test('4. CLEARS a genuine orphan (no live container carries that name)', () => {
const out = pendingRowsToClear(
[row('shop-gone-1', 'old-id')],
[live('shop-web-1', LATEST, STACK)], // different name still around
latestIdForTag, STACK
);
expect(out).toEqual(['old-id']);
});
test('5. leaves another stack\'s container untouched', () => {
const out = pendingRowsToClear(
[row('other-web-1', 'old-id')],
[live('other-web-1', LATEST, 'other-stack')], // same name, different project
latestIdForTag, STACK
);
expect(out).toEqual([]);
});
test('6. leaves a standalone (no project) container untouched', () => {
const out = pendingRowsToClear(
[row('lonely', 'old-id')],
[live('lonely', LATEST, undefined)],
latestIdForTag, STACK
);
expect(out).toEqual([]);
});
test('7a. empty pending -> []', () => {
expect(pendingRowsToClear([], [live('shop-web-1', LATEST, STACK)], latestIdForTag, STACK)).toEqual([]);
});
test('7b. empty live -> every row is a true orphan, all cleared', () => {
const out = pendingRowsToClear(
[row('shop-web-1', 'a'), row('shop-db-1', 'b')],
[], latestIdForTag, STACK
);
expect(out.sort()).toEqual(['a', 'b']);
});
test('7c. mixed batch: updated cleared, still-old kept, other-stack kept, orphan cleared', () => {
const out = pendingRowsToClear(
[
row('shop-web-1', 'web'), // updated -> clear
row('shop-db-1', 'db'), // still old -> keep
row('other-x-1', 'other'), // other stack -> keep
row('shop-gone-1', 'gone') // orphan -> clear
],
[
live('shop-web-1', LATEST, STACK),
live('shop-db-1', OLD, STACK),
live('other-x-1', LATEST, 'other-stack')
],
latestIdForTag, STACK
);
expect(out.sort()).toEqual(['gone', 'web']);
});
test('7d. does not throw on malformed rows / missing fields', () => {
const out = pendingRowsToClear(
[{ containerId: '', containerName: 'x', currentImage: 'nginx:latest' } as PendingRow,
row('shop-web-1', 'ok')],
[live('shop-web-1', LATEST, STACK)],
latestIdForTag, STACK
);
expect(out).toEqual(['ok']); // blank-id row skipped, valid one cleared
});
test('non-array pending -> []', () => {
// @ts-expect-error deliberate wrong type
expect(pendingRowsToClear(null, [], latestIdForTag, STACK)).toEqual([]);
});
});
describe('countLivePending (#1006 dashboard tile)', () => {
const p = (id: string) => ({ containerId: id });
test('counts only rows whose containerId is a live container', () => {
expect(countLivePending([p('a'), p('b'), p('c')], ['a', 'c'])).toBe(2);
});
test('orphan rows (recreated/removed out-of-band) are not counted', () => {
// old ids gone after a compose-CLI recreate; none map to a live id
expect(countLivePending([p('old1'), p('old2')], ['new1', 'new2'])).toBe(0);
});
test('accepts a Set of live ids', () => {
expect(countLivePending([p('a'), p('b')], new Set(['b']))).toBe(1);
});
test('null/empty pending -> 0', () => {
// @ts-expect-error deliberate null
expect(countLivePending(null, ['a'])).toBe(0);
expect(countLivePending([], ['a'])).toBe(0);
});
// CONTRACT the call-site regression-guard depends on: with an EMPTY live list the
// helper returns 0. That is correct here, which is exactly why the dashboard handler
// must NOT feed it an empty list caused by a listContainers timeout (it would hide
// real updates). The handler falls back to the raw count when containers.length === 0.
test('empty live list -> 0 (guard must handle the timeout case at the call-site)', () => {
expect(countLivePending([p('a'), p('b')], [])).toBe(0);
});
});