import Database from 'better-sqlite3'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it } from 'vitest'; import { migrateDatabase } from '../../infrastructure/database/migrations.js'; import { SystemMaintenanceService } from './system-maintenance-service.js'; const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); function database(): Database.Database { const db = new Database(':memory:'); db.pragma('foreign_keys = ON'); migrateDatabase(db); return db; } function insertAudit(db: Database.Database, id: string, createdAt: string): void { db.prepare( `INSERT INTO audit_events (id,actor,operation_id,risk_level,request_id,parameters_summary_json,result_code,duration_ms,created_at) VALUES (?,?,?,?,?,?,?,?,?)`, ).run(id, 'operator', 'probe', 'R0', `${id}-request`, '{}', 'success', 1, createdAt); } function insertJob(db: Database.Database, id: string, createdAt: string): void { db.prepare( `INSERT INTO jobs (id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`, ).run( id, 'probe', 'R0', 'succeeded', 'operator', `${id}-request`, '0'.repeat(64), createdAt, createdAt, ); } function insertEvent(db: Database.Database, publicId: string, envelope: unknown): void { db.prepare('INSERT INTO event_journal (public_id,envelope_json) VALUES (?,?)').run( publicId, JSON.stringify(envelope), ); } describe('SystemMaintenanceService', () => { it('reports runtime storage, component counts, and Hub-compatible default retention', async () => { const db = database(); insertAudit(db, 'audit-1', '2026-01-01T00:00:00.000Z'); insertJob(db, 'job-1', '2026-01-01T00:00:00.000Z'); insertEvent(db, 'event-1', { kind: 'test' }); const service = new SystemMaintenanceService(db, { version: '1.9.9' }); const overview = await service.overview(); expect(overview.runtime).toMatchObject({ version: '1.9.9', platform: process.platform, arch: process.arch, }); expect(overview.runtime.uptimeSeconds).toBeGreaterThanOrEqual(0); expect(overview.storage.databaseBytes).toBeGreaterThan(0); expect( Object.fromEntries(overview.storage.components.map((item) => [item.key, item.count])), ).toEqual({ instances: 0, statusSnapshots: 0, jobs: 1, auditEvents: 1, scheduledRuns: 0, notificationQueue: 0, smsMessages: 0, eventJournal: 1, connectionLogs: 0, }); expect(overview.retention.auditEvents).toEqual({ enabled: true, days: 180, maximumCount: 50_000, }); expect(overview.retention.jobs).toEqual({ enabled: true, days: 90, maximumCount: 5_000, }); }); it('persists validated retention policies and cleans selected components', async () => { const db = database(); const task = { name: 'Night restart', operationType: 'restart-service' as const, cronExpression: '0 2 * * *', timezone: 'Asia/Shanghai' as const, targetSelector: { mode: 'fixed' as const, instanceIds: ['instance-a'] }, misfirePolicy: 'skip' as const, overlapPolicy: 'skip' as const, retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, enabled: false, }; db.prepare( `INSERT INTO scheduled_tasks (id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json, misfire_policy,overlap_policy,retry_policy_json,created_by,updated_by,created_at,updated_at) VALUES ('task-1',?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ).run( task.name, task.operationType, 0, 1, task.cronExpression, task.timezone, JSON.stringify(task.targetSelector), task.misfirePolicy, task.overlapPolicy, JSON.stringify(task.retryPolicy), 'operator', 'operator', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', ); db.prepare( `INSERT INTO scheduled_runs (id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at, target_snapshot_json,job_ids_json,trigger_source,attempt) VALUES ('run-old','task-1',1,?,'2026-01-01T02:00:00.000Z','2026-01-01T02:00:00.000Z','[]','[]','scheduled',1)`, ).run(JSON.stringify(task)); insertAudit(db, 'audit-old', '2026-01-01T00:00:00.000Z'); insertAudit(db, 'audit-new', '2026-09-01T00:00:00.000Z'); insertJob(db, 'job-old', '2026-01-01T00:00:00.000Z'); insertEvent(db, 'event-old', { kind: 'old' }); const service = new SystemMaintenanceService(db, { version: '1.9.9', now: () => new Date('2026-09-03T00:00:00.000Z'), }); await service.updateRetention({ auditEvents: { enabled: true, days: 30, maximumCount: 1 }, jobs: { enabled: true, days: 1, maximumCount: 100 }, eventJournal: { enabled: true, days: 1, maximumCount: 100 }, }); const result = await service.cleanup(['auditEvents', 'jobs', 'eventJournal', 'scheduledRuns']); expect(result).toEqual({ auditEvents: 1, jobs: 1, eventJournal: 1, scheduledRuns: 1, }); const counts = (table: string) => (db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count; expect(counts('audit_events')).toBe(1); expect(counts('jobs')).toBe(0); expect(counts('event_journal')).toBe(0); expect(counts('scheduled_runs')).toBe(0); expect((await service.overview()).retention.auditEvents).toEqual({ enabled: true, days: 30, maximumCount: 1, }); }); it('rejects unknown components and invalid retention values without touching settings', async () => { const db = database(); const service = new SystemMaintenanceService(db, { version: '1.9.9' }); await expect(service.cleanup(['unknown' as never])).rejects.toThrow('Unknown data component'); await expect( service.updateRetention({ auditEvents: { enabled: true, days: 0, maximumCount: 1 } }), ).rejects.toThrow('Retention days must be between'); expect(await service.getRetention()).toEqual(await service.defaultRetention()); }); it('optimizes SQLite and creates validated, listed backups with retention pruning', async () => { const db = database(); const backupRoot = await mkdtemp(join(tmpdir(), 'multi-simadmin-backup-')); roots.push(backupRoot); const service = new SystemMaintenanceService(db, { version: '1.9.9', backupDirectory: backupRoot, maximumBackups: 2, }); expect(await service.optimize()).toEqual({ checkpointed: true, vacuumed: true, analyzed: true, }); const first = await service.createBackup(); const second = await service.createBackup(); const third = await service.createBackup(); const backups = await service.listBackups(); expect(backups).toHaveLength(2); expect(backups.map((item) => item.filename)).toEqual([third.filename, second.filename]); expect(first.sha256).toMatch(/^[a-f0-9]{64}$/u); // Retention is enforced on disk, not just in the listing. await expect(readFile(first.path)).rejects.toThrow(); expect(await readFile(second.path)).toBeTruthy(); expect(backups[0]).toEqual( expect.objectContaining({ filename: third.filename, sha256: third.sha256, sizeBytes: expect.any(Number), createdAt: expect.any(String), }), ); }); it('serves and deletes single backups and refuses names outside the directory', async () => { const db = database(); const backupRoot = await mkdtemp(join(tmpdir(), 'multi-simadmin-backup-')); roots.push(backupRoot); const service = new SystemMaintenanceService(db, { version: '1.9.9', backupDirectory: backupRoot, }); const backup = await service.createBackup(); expect(await service.backupFile(backup.filename)).toEqual({ filename: backup.filename, path: backup.path, sizeBytes: backup.sizeBytes, createdAt: expect.any(String), }); expect(await service.backupFile('multi-simadmin-1970-01-01T00-00-00-000.db')).toBeUndefined(); await expect(service.backupFile('multi-simadmin-../secret.db')).rejects.toThrow(); await expect(service.backupFile('multi-simadmin-notes.txt')).rejects.toThrow(); await expect(service.deleteBackup('multi-simadmin-%00.db')).rejects.toThrow(); expect(await service.deleteBackup(backup.filename)).toEqual({ filename: backup.filename }); expect(await service.listBackups()).toEqual([]); }); });