Files
dockhand-upstream/tests/container-labels.test.ts

368 lines
14 KiB
TypeScript

/**
* Unit tests for Dockhand container label controls.
*
* Tests the label parsing and behavior for:
* - dockhand.update (opt-out from auto-updates)
* - dockhand.hidden (hide from UI)
* - dockhand.notify (suppress notifications)
*
* Run with: bun test tests/unit/container-labels.test.ts
*/
import { describe, test, expect } from 'bun:test';
import {
isUpdateDisabledByLabel,
isHiddenByLabel,
isNotifyDisabledByLabel,
isAdoptDisabledByLabel,
isStackUnadoptable,
getDockhandLabels,
getOrderValue,
getVersionPatternOverride,
DOCKHAND_LABELS,
} from '../src/lib/server/container-labels';
// ---------------------------------------------------------------------------
// dockhand.update
// ---------------------------------------------------------------------------
describe('isUpdateDisabledByLabel', () => {
test('returns false (allow update) when no labels present', () => {
expect(isUpdateDisabledByLabel(undefined)).toBe(false);
expect(isUpdateDisabledByLabel(null)).toBe(false);
expect(isUpdateDisabledByLabel({})).toBe(false);
});
test('returns true (skip update) for falsy values', () => {
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'false' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'FALSE' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'False' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'no' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'NO' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'No' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': '0' })).toBe(true);
});
test('returns false (allow update) for truthy values', () => {
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'true' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'TRUE' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'yes' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'YES' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': '1' })).toBe(false);
});
test('returns false (allow update) for unrecognized values', () => {
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'maybe' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': '' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': 'disable' })).toBe(false);
});
test('handles whitespace in values', () => {
expect(isUpdateDisabledByLabel({ 'dockhand.update': ' false ' })).toBe(true);
expect(isUpdateDisabledByLabel({ 'dockhand.update': ' true ' })).toBe(false);
expect(isUpdateDisabledByLabel({ 'dockhand.update': ' NO ' })).toBe(true);
});
test('ignores unrelated labels', () => {
expect(isUpdateDisabledByLabel({ 'com.docker.compose.project': 'myapp' })).toBe(false);
expect(isUpdateDisabledByLabel({
'com.docker.compose.project': 'myapp',
'dockhand.update': 'false'
})).toBe(true);
});
});
// ---------------------------------------------------------------------------
// dockhand.hidden
// ---------------------------------------------------------------------------
describe('isHiddenByLabel', () => {
test('returns false (visible) when no labels present', () => {
expect(isHiddenByLabel(undefined)).toBe(false);
expect(isHiddenByLabel(null)).toBe(false);
expect(isHiddenByLabel({})).toBe(false);
});
test('returns true (hidden) for truthy values', () => {
expect(isHiddenByLabel({ 'dockhand.hidden': 'true' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': 'TRUE' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': 'True' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': 'yes' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': 'YES' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': 'Yes' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': '1' })).toBe(true);
});
test('returns false (visible) for falsy values', () => {
expect(isHiddenByLabel({ 'dockhand.hidden': 'false' })).toBe(false);
expect(isHiddenByLabel({ 'dockhand.hidden': 'no' })).toBe(false);
expect(isHiddenByLabel({ 'dockhand.hidden': '0' })).toBe(false);
});
test('returns false (visible) for unrecognized values', () => {
expect(isHiddenByLabel({ 'dockhand.hidden': 'hidden' })).toBe(false);
expect(isHiddenByLabel({ 'dockhand.hidden': '' })).toBe(false);
});
test('handles whitespace in values', () => {
expect(isHiddenByLabel({ 'dockhand.hidden': ' true ' })).toBe(true);
expect(isHiddenByLabel({ 'dockhand.hidden': ' YES ' })).toBe(true);
});
});
// ---------------------------------------------------------------------------
// dockhand.notify
// ---------------------------------------------------------------------------
describe('isNotifyDisabledByLabel', () => {
test('returns false (notify) when no labels present', () => {
expect(isNotifyDisabledByLabel(undefined)).toBe(false);
expect(isNotifyDisabledByLabel(null)).toBe(false);
expect(isNotifyDisabledByLabel({})).toBe(false);
});
test('returns true (suppress) for falsy values', () => {
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'false' })).toBe(true);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'FALSE' })).toBe(true);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'no' })).toBe(true);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'NO' })).toBe(true);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': '0' })).toBe(true);
});
test('returns false (notify) for truthy values', () => {
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'true' })).toBe(false);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'yes' })).toBe(false);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': '1' })).toBe(false);
});
test('returns false (notify) for unrecognized values', () => {
expect(isNotifyDisabledByLabel({ 'dockhand.notify': 'off' })).toBe(false);
expect(isNotifyDisabledByLabel({ 'dockhand.notify': '' })).toBe(false);
});
});
// ---------------------------------------------------------------------------
// getDockhandLabels (combined)
// ---------------------------------------------------------------------------
describe('getDockhandLabels', () => {
test('returns all defaults when no labels', () => {
expect(getDockhandLabels({})).toEqual({
updateDisabled: false,
hidden: false,
notifyDisabled: false,
});
});
test('returns all defaults for null/undefined', () => {
expect(getDockhandLabels(null)).toEqual({
updateDisabled: false,
hidden: false,
notifyDisabled: false,
});
expect(getDockhandLabels(undefined)).toEqual({
updateDisabled: false,
hidden: false,
notifyDisabled: false,
});
});
test('parses all labels correctly', () => {
expect(getDockhandLabels({
'dockhand.update': 'false',
'dockhand.hidden': 'true',
'dockhand.notify': 'no',
})).toEqual({
updateDisabled: true,
hidden: true,
notifyDisabled: true,
});
});
test('parses mixed labels', () => {
expect(getDockhandLabels({
'dockhand.update': 'true',
'dockhand.hidden': 'false',
'dockhand.notify': 'yes',
})).toEqual({
updateDisabled: false,
hidden: false,
notifyDisabled: false,
});
});
test('handles partial label sets', () => {
expect(getDockhandLabels({
'dockhand.update': 'no',
})).toEqual({
updateDisabled: true,
hidden: false,
notifyDisabled: false,
});
});
test('ignores non-dockhand labels', () => {
expect(getDockhandLabels({
'com.docker.compose.project': 'mystack',
'maintainer': '[email protected]',
'dockhand.hidden': 'YES',
})).toEqual({
updateDisabled: false,
hidden: true,
notifyDisabled: false,
});
});
});
// ---------------------------------------------------------------------------
// DOCKHAND_LABELS constants
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// dockhand.order
// ---------------------------------------------------------------------------
describe('getOrderValue', () => {
test('returns 0 for null/undefined/empty/missing labels', () => {
expect(getOrderValue(undefined)).toBe(0);
expect(getOrderValue(null)).toBe(0);
expect(getOrderValue({})).toBe(0);
});
test('parses valid integers', () => {
expect(getOrderValue({ 'dockhand.order': '1' })).toBe(1);
expect(getOrderValue({ 'dockhand.order': '-5' })).toBe(-5);
expect(getOrderValue({ 'dockhand.order': '100' })).toBe(100);
expect(getOrderValue({ 'dockhand.order': '0' })).toBe(0);
});
test('returns 0 for non-numeric values', () => {
expect(getOrderValue({ 'dockhand.order': 'abc' })).toBe(0);
expect(getOrderValue({ 'dockhand.order': 'first' })).toBe(0);
expect(getOrderValue({ 'dockhand.order': '' })).toBe(0);
});
test('truncates floats to integer', () => {
expect(getOrderValue({ 'dockhand.order': '1.5' })).toBe(1);
expect(getOrderValue({ 'dockhand.order': '3.9' })).toBe(3);
});
test('handles whitespace', () => {
expect(getOrderValue({ 'dockhand.order': ' 3 ' })).toBe(3);
expect(getOrderValue({ 'dockhand.order': ' -2 ' })).toBe(-2);
});
});
describe('DOCKHAND_LABELS constants', () => {
test('has correct label keys', () => {
expect(DOCKHAND_LABELS.UPDATE).toBe('dockhand.update');
expect(DOCKHAND_LABELS.HIDDEN).toBe('dockhand.hidden');
expect(DOCKHAND_LABELS.NOTIFY).toBe('dockhand.notify');
expect(DOCKHAND_LABELS.ORDER).toBe('dockhand.order');
});
});
// ---------------------------------------------------------------------------
// Integration-style: simulated container filtering
// ---------------------------------------------------------------------------
describe('container filtering simulation', () => {
interface MockContainer {
id: string;
name: string;
labels: Record<string, string>;
}
const containers: MockContainer[] = [
{ id: 'c1', name: 'nginx', labels: {} },
{ id: 'c2', name: 'redis', labels: { 'dockhand.update': 'false' } },
{ id: 'c3', name: 'postgres', labels: { 'dockhand.update': 'NO' } },
{ id: 'c4', name: 'traefik', labels: { 'dockhand.hidden': 'true' } },
{ id: 'c5', name: 'mqtt', labels: { 'dockhand.notify': 'false' } },
{ id: 'c6', name: 'app', labels: { 'dockhand.update': 'true' } },
{ id: 'c7', name: 'socket-proxy', labels: { 'dockhand.update': '0', 'dockhand.hidden': 'YES', 'dockhand.notify': 'NO' } },
];
test('filters out update-disabled containers', () => {
const updatable = containers.filter(c => !isUpdateDisabledByLabel(c.labels));
expect(updatable.map(c => c.name)).toEqual(['nginx', 'traefik', 'mqtt', 'app']);
});
test('filters out hidden containers', () => {
const visible = containers.filter(c => !isHiddenByLabel(c.labels));
expect(visible.map(c => c.name)).toEqual(['nginx', 'redis', 'postgres', 'mqtt', 'app']);
});
test('filters out notify-disabled containers', () => {
const notifiable = containers.filter(c => !isNotifyDisabledByLabel(c.labels));
expect(notifiable.map(c => c.name)).toEqual(['nginx', 'redis', 'postgres', 'traefik', 'app']);
});
test('socket-proxy has all three labels active', () => {
const sp = containers.find(c => c.name === 'socket-proxy')!;
expect(isUpdateDisabledByLabel(sp.labels)).toBe(true);
expect(isHiddenByLabel(sp.labels)).toBe(true);
expect(isNotifyDisabledByLabel(sp.labels)).toBe(true);
});
});
describe('isAdoptDisabledByLabel', () => {
test('default (no label) is adoptable', () => {
expect(isAdoptDisabledByLabel(undefined)).toBe(false);
expect(isAdoptDisabledByLabel(null)).toBe(false);
expect(isAdoptDisabledByLabel({})).toBe(false);
});
test('explicit false/no/0 disables adoption (case-insensitive)', () => {
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': 'false' })).toBe(true);
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': 'FALSE' })).toBe(true);
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': 'no' })).toBe(true);
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': '0' })).toBe(true);
});
test('true / unrecognized values stay adoptable', () => {
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': 'true' })).toBe(false);
expect(isAdoptDisabledByLabel({ 'dockhand.adopt': 'maybe' })).toBe(false);
});
});
describe('isStackUnadoptable', () => {
test('false when no container opts out', () => {
expect(isStackUnadoptable([])).toBe(false);
expect(isStackUnadoptable([{}, { 'dockhand.update': 'false' }])).toBe(false);
});
test('true when ANY container has dockhand.adopt=false', () => {
expect(isStackUnadoptable([{}, { 'dockhand.adopt': 'false' }])).toBe(true);
expect(isStackUnadoptable([{ 'dockhand.adopt': 'no' }])).toBe(true);
});
test('tolerates null/undefined entries', () => {
expect(isStackUnadoptable([null, undefined, { 'dockhand.adopt': '0' }])).toBe(true);
expect(isStackUnadoptable([null, undefined])).toBe(false);
});
});
// ---------------------------------------------------------------------------
// dockhand.version.pattern
// ---------------------------------------------------------------------------
describe('getVersionPatternOverride', () => {
test('returns null when the label is absent', () => {
expect(getVersionPatternOverride({})).toBeNull();
expect(getVersionPatternOverride(null)).toBeNull();
});
test('compiles a valid regex: pattern from the label into a RegExp', () => {
const re = getVersionPatternOverride({
'dockhand.version.pattern': 'regex:^(?<major>\\d{4})\\.(?<minor>\\d+)\\.(?<patch>\\d+)-[0-9a-f]+$'
});
expect(re).toBeInstanceOf(RegExp);
expect(re!.test('2024.12.5-a1b2c3d')).toBe(true);
});
test('returns null for a malformed label (safe fallback)', () => {
expect(getVersionPatternOverride({ 'dockhand.version.pattern': 'not-a-scheme' })).toBeNull();
expect(getVersionPatternOverride({ 'dockhand.version.pattern': 'regex:(' })).toBeNull();
});
});