154 lines
5.6 KiB
TypeScript
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);
|
|
});
|
|
});
|