mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
72cdbb0eaa
* feat(schedules): auto-update stacks by Stack Label Add a reusable selector_type/selector_value on scheduled tasks so admins can schedule image updates against live Stack Label membership across the fleet or one node, reusing fleet label resolution and the existing auto-update orchestrator. * fix(image-updates): sanitize auto-update execute failure logs Use a static format string and sanitizeForLog so CodeQL no longer flags user-controlled stack names and error text in the execute catch. * fix(ui): space Scope label from fleet/node segmented control Match the Schedule row layout so the inline SegmentedControl no longer sits flush against the Scope label. * fix(ui): remove redundant wrapper around Scope segmented control
222 lines
8.0 KiB
TypeScript
222 lines
8.0 KiB
TypeScript
/**
|
|
* Tests for cascade delete behavior in DatabaseService.
|
|
*
|
|
* Verifies that deleting a scheduled task also removes its runs,
|
|
* and deleting a node cascades through tasks and their runs.
|
|
*
|
|
* Uses an in-memory SQLite database (via better-sqlite3 directly)
|
|
* to test actual SQL behavior without touching disk.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
|
|
// Minimal schema for the tables we need
|
|
const SCHEMA = `
|
|
CREATE TABLE IF NOT EXISTS nodes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL DEFAULT 'local',
|
|
status TEXT DEFAULT 'online',
|
|
is_default INTEGER DEFAULT 0,
|
|
api_url TEXT,
|
|
api_token TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
target_type TEXT NOT NULL DEFAULT 'stack',
|
|
target_id TEXT,
|
|
node_id INTEGER,
|
|
action TEXT NOT NULL DEFAULT 'update',
|
|
cron_expression TEXT NOT NULL,
|
|
enabled INTEGER DEFAULT 1,
|
|
created_by TEXT NOT NULL DEFAULT 'admin',
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
last_run_at INTEGER,
|
|
next_run_at INTEGER,
|
|
last_status TEXT,
|
|
last_error TEXT,
|
|
prune_targets TEXT,
|
|
target_services TEXT,
|
|
prune_label_filter TEXT,
|
|
selector_type TEXT,
|
|
selector_value TEXT,
|
|
FOREIGN KEY(node_id) REFERENCES nodes(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
task_id INTEGER NOT NULL,
|
|
started_at INTEGER NOT NULL,
|
|
completed_at INTEGER,
|
|
status TEXT NOT NULL DEFAULT 'running',
|
|
output TEXT,
|
|
error TEXT,
|
|
triggered_by TEXT DEFAULT 'scheduler',
|
|
FOREIGN KEY(task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS stack_update_status (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
node_id INTEGER NOT NULL,
|
|
stack_name TEXT NOT NULL,
|
|
has_update INTEGER DEFAULT 0,
|
|
checked_at INTEGER
|
|
);
|
|
`;
|
|
|
|
describe('Cascade delete behavior (in-memory SQLite)', () => {
|
|
let db: Database.Database;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
db.prepare(SCHEMA.split(';').filter(s => s.trim())[0] + ';').run();
|
|
db.prepare(SCHEMA.split(';').filter(s => s.trim())[1] + ';').run();
|
|
db.prepare(SCHEMA.split(';').filter(s => s.trim())[2] + ';').run();
|
|
db.prepare(SCHEMA.split(';').filter(s => s.trim())[3] + ';').run();
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
});
|
|
|
|
function insertNode(name = 'test-node'): number {
|
|
return db.prepare(
|
|
'INSERT INTO nodes (name, type, status, is_default) VALUES (?, ?, ?, ?)'
|
|
).run(name, 'local', 'online', 0).lastInsertRowid as number;
|
|
}
|
|
|
|
function insertTask(nodeId: number, name = 'test-task'): number {
|
|
return db.prepare(
|
|
'INSERT INTO scheduled_tasks (name, node_id, action, cron_expression, created_by) VALUES (?, ?, ?, ?, ?)'
|
|
).run(name, nodeId, 'update', '0 3 * * *', 'admin').lastInsertRowid as number;
|
|
}
|
|
|
|
function insertRun(taskId: number): number {
|
|
return db.prepare(
|
|
'INSERT INTO scheduled_task_runs (task_id, started_at, status, triggered_by) VALUES (?, ?, ?, ?)'
|
|
).run(taskId, Date.now(), 'success', 'scheduler').lastInsertRowid as number;
|
|
}
|
|
|
|
function insertStackStatus(nodeId: number, stackName: string): void {
|
|
db.prepare(
|
|
'INSERT INTO stack_update_status (node_id, stack_name, has_update, checked_at) VALUES (?, ?, ?, ?)'
|
|
).run(nodeId, stackName, 0, Date.now());
|
|
}
|
|
|
|
function countRows(table: string): number {
|
|
return (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c;
|
|
}
|
|
|
|
// ── deleteScheduledTask cascade ─────────────────────────────────────
|
|
|
|
describe('deleteScheduledTask cascade', () => {
|
|
it('removes associated runs when deleting a task', () => {
|
|
const nodeId = insertNode();
|
|
const taskId = insertTask(nodeId);
|
|
insertRun(taskId);
|
|
insertRun(taskId);
|
|
insertRun(taskId);
|
|
|
|
expect(countRows('scheduled_task_runs')).toBe(3);
|
|
|
|
// Simulate the cascade delete (same logic as DatabaseService.deleteScheduledTask)
|
|
db.transaction(() => {
|
|
db.prepare('DELETE FROM scheduled_task_runs WHERE task_id = ?').run(taskId);
|
|
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(taskId);
|
|
})();
|
|
|
|
expect(countRows('scheduled_tasks')).toBe(0);
|
|
expect(countRows('scheduled_task_runs')).toBe(0);
|
|
});
|
|
|
|
it('only removes runs for the deleted task, not other tasks', () => {
|
|
const nodeId = insertNode();
|
|
const task1 = insertTask(nodeId, 'task-1');
|
|
const task2 = insertTask(nodeId, 'task-2');
|
|
insertRun(task1);
|
|
insertRun(task1);
|
|
insertRun(task2);
|
|
insertRun(task2);
|
|
|
|
expect(countRows('scheduled_task_runs')).toBe(4);
|
|
|
|
db.transaction(() => {
|
|
db.prepare('DELETE FROM scheduled_task_runs WHERE task_id = ?').run(task1);
|
|
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(task1);
|
|
})();
|
|
|
|
expect(countRows('scheduled_tasks')).toBe(1);
|
|
expect(countRows('scheduled_task_runs')).toBe(2);
|
|
|
|
// Remaining runs belong to task2
|
|
const remaining = db.prepare('SELECT task_id FROM scheduled_task_runs').all() as { task_id: number }[];
|
|
expect(remaining.every(r => r.task_id === task2)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── deleteNode cascade ──────────────────────────────────────────────
|
|
|
|
describe('deleteNode cascade', () => {
|
|
it('removes tasks, runs, and status when deleting a node', () => {
|
|
const nodeId = insertNode();
|
|
const task1 = insertTask(nodeId, 'task-a');
|
|
const task2 = insertTask(nodeId, 'task-b');
|
|
insertRun(task1);
|
|
insertRun(task1);
|
|
insertRun(task2);
|
|
insertStackStatus(nodeId, 'my-stack');
|
|
|
|
expect(countRows('nodes')).toBe(1);
|
|
expect(countRows('scheduled_tasks')).toBe(2);
|
|
expect(countRows('scheduled_task_runs')).toBe(3);
|
|
expect(countRows('stack_update_status')).toBe(1);
|
|
|
|
// Simulate the cascade delete (same logic as DatabaseService.deleteNode)
|
|
db.transaction(() => {
|
|
db.prepare('DELETE FROM scheduled_task_runs WHERE task_id IN (SELECT id FROM scheduled_tasks WHERE node_id = ?)').run(nodeId);
|
|
db.prepare('DELETE FROM scheduled_tasks WHERE node_id = ?').run(nodeId);
|
|
db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(nodeId);
|
|
db.prepare('DELETE FROM nodes WHERE id = ?').run(nodeId);
|
|
})();
|
|
|
|
expect(countRows('nodes')).toBe(0);
|
|
expect(countRows('scheduled_tasks')).toBe(0);
|
|
expect(countRows('scheduled_task_runs')).toBe(0);
|
|
expect(countRows('stack_update_status')).toBe(0);
|
|
});
|
|
|
|
it('does not affect other nodes or their data', () => {
|
|
const node1 = insertNode('node-1');
|
|
const node2 = insertNode('node-2');
|
|
const task1 = insertTask(node1, 'task-node1');
|
|
const task2 = insertTask(node2, 'task-node2');
|
|
insertRun(task1);
|
|
insertRun(task2);
|
|
insertStackStatus(node1, 'stack-1');
|
|
insertStackStatus(node2, 'stack-2');
|
|
|
|
// Delete node1
|
|
db.transaction(() => {
|
|
db.prepare('DELETE FROM scheduled_task_runs WHERE task_id IN (SELECT id FROM scheduled_tasks WHERE node_id = ?)').run(node1);
|
|
db.prepare('DELETE FROM scheduled_tasks WHERE node_id = ?').run(node1);
|
|
db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(node1);
|
|
db.prepare('DELETE FROM nodes WHERE id = ?').run(node1);
|
|
})();
|
|
|
|
// node2 data should be untouched
|
|
expect(countRows('nodes')).toBe(1);
|
|
expect(countRows('scheduled_tasks')).toBe(1);
|
|
expect(countRows('scheduled_task_runs')).toBe(1);
|
|
expect(countRows('stack_update_status')).toBe(1);
|
|
|
|
const remainingNode = db.prepare('SELECT name FROM nodes').get() as { name: string };
|
|
expect(remainingNode.name).toBe('node-2');
|
|
});
|
|
});
|
|
});
|