import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { closeDb, getDb } from '../../db/client'; import { runMigrations } from '../../db/migrate'; import { moduleOperations } from '../../db/schema'; import { ABANDONED_RELEASE_MESSAGE, OPERATION_TTL_MS } from '../../services/module-operations'; import { resetTestDbPath } from '../../test-utils/db-path'; import { handleModuleOperations } from './module-operations'; describe('celilo module operations', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-ops-cmd-test-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); await runMigrations(process.env.CELILO_DB_PATH); }); afterEach(() => { closeDb(); resetTestDbPath(); rmSync(dir, { recursive: true, force: true }); }); function insert(id: string, pid: number, ageMs: number): void { getDb() .insert(moduleOperations) .values({ id, moduleId: 'byoi', operation: 'deploy', status: 'in_progress', pid, startedAt: new Date(Date.now() - ageMs), }) .run(); } function statusOf(id: string): string | undefined { return getDb().select().from(moduleOperations).where(eq(moduleOperations.id, id)).get()?.status; } it('reports nothing to clear when the lock is genuinely held', () => { insert('live', process.pid, 60_000); const result = handleModuleOperations(['clear'], {}); if (!result.success) throw new Error(`expected success, got: ${result.error}`); expect(result.message).toContain('still look genuinely in flight'); expect(statusOf('live')).toBe('in_progress'); }); it('releases an expired row without touching a live one', () => { insert('expired', process.pid, OPERATION_TTL_MS + 60_000); insert('live', process.pid, 60_000); const result = handleModuleOperations(['clear'], {}); expect(result.success).toBe(true); expect(statusOf('expired')).toBe('failed'); expect(statusOf('live')).toBe('in_progress'); }); // The escape hatch's reason for existing: when liveness detection is wrong // — a recycled pid reads as perfectly healthy — refusing to clear would // recreate the outage the command exists to end. it('--all releases a row whose process is still alive', () => { insert('live', process.pid, 60_000); const result = handleModuleOperations(['clear'], { all: true }); expect(result.success).toBe(true); expect(statusOf('live')).toBe('failed'); }); it('lists without mutating anything', () => { insert('expired', process.pid, OPERATION_TTL_MS + 60_000); const result = handleModuleOperations([], {}); if (!result.success) throw new Error(`expected success, got: ${result.error}`); expect(result.message).toContain('1 abandoned'); expect(statusOf('expired')).toBe('in_progress'); }); // The recurrence gate for #581: the sweep runs on every hourly tick, so an // abandoned row must be reclaimed once and then stop being work. Before // this, rows only ever accumulated — 85 of them, the oldest 62 days old. it('reclaims an abandoned row and does not re-collect it on the next sweep', () => { insert('expired', process.pid, OPERATION_TTL_MS + 60_000); const first = handleModuleOperations(['clear'], {}); if (!first.success) throw new Error(`expected success, got: ${first.error}`); expect(first.message).toContain('Released 1'); expect(statusOf('expired')).toBe('failed'); const second = handleModuleOperations(['clear'], {}); if (!second.success) throw new Error(`expected success, got: ${second.error}`); expect(second.message).toContain('no operations in progress'); // Reclaimed, not deleted: the released row is the evidence the // abandoned-operations audit reads. const row = getDb() .select() .from(moduleOperations) .where(eq(moduleOperations.id, 'expired')) .get(); expect(row?.errorMessage).toBe(ABANDONED_RELEASE_MESSAGE); }); it('hides abandoned rows from list by default and shows them with --abandoned', () => { insert('expired', process.pid, OPERATION_TTL_MS + 60_000); const lines: string[] = []; const original = console.log; console.log = (msg?: unknown) => lines.push(String(msg)); try { handleModuleOperations(['list'], {}); expect(lines.join('\n')).not.toContain('pid'); expect(lines.join('\n')).toContain('1 abandoned row(s) hidden'); lines.length = 0; handleModuleOperations(['list'], { abandoned: true }); expect(lines.join('\n')).toContain('abandoned (expired)'); } finally { console.log = original; } }); it('rejects an unknown action rather than silently listing', () => { const result = handleModuleOperations(['nuke'], {}); if (result.success) throw new Error('expected an unknown action to fail'); expect(result.error).toContain('Unknown action'); }); });