import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { initDatabaseAsync, closeDatabase } from '../db/index'; import { runMigrations } from '../db/migrate'; import { createHistoryEvent } from '../db/repositories/history-repository'; import { HealthService } from '../services/health'; import type { SchedulerState } from '../services/scheduler'; // ── Mock yt-dlp ── vi.mock('../sources/yt-dlp', () => ({ getYtDlpVersion: vi.fn(), })); // ── Mock statfs ── vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, statfs: vi.fn(), }; }); import { getYtDlpVersion } from '../sources/yt-dlp'; import { statfs } from 'node:fs/promises'; const mockGetYtDlpVersion = vi.mocked(getYtDlpVersion); const mockStatfs = vi.mocked(statfs); // ── Test Helpers ── let tmpDir: string; let db: Awaited>; async function setupDb(): Promise { tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-health-')); const dbPath = join(tmpDir, 'test.db'); db = await initDatabaseAsync(dbPath); await runMigrations(dbPath); } function cleanup(): void { closeDatabase(); try { if (tmpDir && existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } } catch { // Windows cleanup best-effort } } function makeSchedulerState(overrides?: Partial): SchedulerState { return { running: true, channelCount: 3, channels: [], ...overrides, }; } function makeStatfsResult(availableRatio: number, totalBlocks = 1000000) { // bsize=4096, total blocks = totalBlocks, available = totalBlocks * ratio const bsize = 4096; const bavail = Math.floor(totalBlocks * availableRatio); return { type: 0, bsize: BigInt(bsize), blocks: BigInt(totalBlocks), bfree: BigInt(bavail), bavail: BigInt(bavail), files: BigInt(0), ffree: BigInt(0), }; } // ── Tests ── describe('HealthService', () => { beforeEach(async () => { await setupDb(); vi.clearAllMocks(); // Default mocks mockGetYtDlpVersion.mockResolvedValue('2024.12.23'); mockStatfs.mockResolvedValue(makeStatfsResult(0.5) as never); // 50% free }); afterEach(() => { cleanup(); }); // ── Scheduler Component ── describe('scheduler component', () => { it('returns healthy with channel count when scheduler is running', async () => { const service = new HealthService( db, () => makeSchedulerState({ running: true, channelCount: 5 }), '/tmp/media' ); const components = await service.getComponentHealth(); const scheduler = components.find((c) => c.name === 'scheduler'); expect(scheduler).toBeDefined(); expect(scheduler!.status).toBe('healthy'); expect(scheduler!.message).toBe('Running — 5 channel(s) monitored'); expect(scheduler!.details).toEqual({ channelCount: 5 }); }); it('returns degraded when scheduler is disabled (null)', async () => { const service = new HealthService(db, () => null, '/tmp/media'); const components = await service.getComponentHealth(); const scheduler = components.find((c) => c.name === 'scheduler'); expect(scheduler!.status).toBe('degraded'); expect(scheduler!.message).toBe('Scheduler disabled'); }); it('returns unhealthy when scheduler is stopped', async () => { const service = new HealthService( db, () => makeSchedulerState({ running: false }), '/tmp/media' ); const components = await service.getComponentHealth(); const scheduler = components.find((c) => c.name === 'scheduler'); expect(scheduler!.status).toBe('unhealthy'); expect(scheduler!.message).toBe('Scheduler stopped'); }); }); // ── yt-dlp Component ── describe('yt-dlp component', () => { it('returns healthy with version when yt-dlp is available', async () => { mockGetYtDlpVersion.mockResolvedValue('2024.12.23'); const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); const ytDlp = components.find((c) => c.name === 'ytDlp'); expect(ytDlp!.status).toBe('healthy'); expect(ytDlp!.message).toBe('yt-dlp 2024.12.23'); expect(ytDlp!.details).toEqual({ version: '2024.12.23' }); }); it('returns unhealthy when yt-dlp is not available', async () => { mockGetYtDlpVersion.mockResolvedValue(null); const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); const ytDlp = components.find((c) => c.name === 'ytDlp'); expect(ytDlp!.status).toBe('unhealthy'); expect(ytDlp!.message).toBe('yt-dlp not found'); }); }); // ── Disk Space Component ── describe('disk space component', () => { it('returns healthy when >10% free', async () => { mockStatfs.mockResolvedValue(makeStatfsResult(0.5) as never); // 50% free const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); const disk = components.find((c) => c.name === 'diskSpace'); expect(disk!.status).toBe('healthy'); expect(disk!.message).toMatch(/GB free of/); expect(disk!.message).toMatch(/50%/); expect(disk!.details).toHaveProperty('availableBytes'); expect(disk!.details).toHaveProperty('totalBytes'); expect(disk!.details).toHaveProperty('freePercent'); }); it('returns degraded when 5-10% free', async () => { mockStatfs.mockResolvedValue(makeStatfsResult(0.07) as never); // 7% free const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); const disk = components.find((c) => c.name === 'diskSpace'); expect(disk!.status).toBe('degraded'); }); it('returns unhealthy when <5% free', async () => { mockStatfs.mockResolvedValue(makeStatfsResult(0.03) as never); // 3% free const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); const disk = components.find((c) => c.name === 'diskSpace'); expect(disk!.status).toBe('unhealthy'); }); it('returns degraded on statfs error', async () => { mockStatfs.mockRejectedValue(new Error('ENOENT: no such file or directory')); const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); const disk = components.find((c) => c.name === 'diskSpace'); expect(disk!.status).toBe('degraded'); expect(disk!.message).toMatch(/Disk check failed/); expect(disk!.message).toMatch(/ENOENT/); }); }); // ── Recent Errors Component ── describe('recent errors component', () => { it('returns healthy when no errors in 24h', async () => { const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); const errors = components.find((c) => c.name === 'recentErrors'); expect(errors!.status).toBe('healthy'); expect(errors!.message).toBe('0 error(s) in the last 24 hours'); }); it('returns degraded when 1-5 errors in 24h', async () => { // Insert 3 failed history events for (let i = 0; i < 3; i++) { await createHistoryEvent(db, { eventType: 'failed', status: 'failed', details: { error: `Test error ${i}` }, }); } const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); const errors = components.find((c) => c.name === 'recentErrors'); expect(errors!.status).toBe('degraded'); expect(errors!.message).toBe('3 error(s) in the last 24 hours'); }); it('returns unhealthy when >5 errors in 24h', async () => { // Insert 7 failed events for (let i = 0; i < 7; i++) { await createHistoryEvent(db, { eventType: 'failed', status: 'failed', details: { error: `Test error ${i}` }, }); } const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); const errors = components.find((c) => c.name === 'recentErrors'); expect(errors!.status).toBe('unhealthy'); expect(errors!.message).toBe('7 error(s) in the last 24 hours'); expect(errors!.details).toEqual({ errorCount: 7 }); }); it('does not count non-failed events', async () => { // Insert a downloaded event — should not count await createHistoryEvent(db, { eventType: 'downloaded', status: 'success', }); // Insert a failed event — should count await createHistoryEvent(db, { eventType: 'failed', status: 'failed', }); const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); const errors = components.find((c) => c.name === 'recentErrors'); expect(errors!.status).toBe('degraded'); expect(errors!.message).toBe('1 error(s) in the last 24 hours'); }); }); // ── Caching ── describe('caching', () => { it('caches yt-dlp version — second call does not invoke getYtDlpVersion', async () => { mockGetYtDlpVersion.mockResolvedValue('2024.12.23'); const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); await service.getComponentHealth(); await service.getComponentHealth(); // getYtDlpVersion should only be called once due to caching expect(mockGetYtDlpVersion).toHaveBeenCalledTimes(1); }); it('caches disk space — second call does not invoke statfs', async () => { mockStatfs.mockResolvedValue(makeStatfsResult(0.5) as never); const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); await service.getComponentHealth(); await service.getComponentHealth(); // statfs should only be called once due to caching expect(mockStatfs).toHaveBeenCalledTimes(1); }); }); // ── Full Response Shape ── describe('full response', () => { it('returns all four components', async () => { const service = new HealthService(db, () => makeSchedulerState(), '/tmp/media'); const components = await service.getComponentHealth(); expect(components).toHaveLength(4); const names = components.map((c) => c.name); expect(names).toContain('scheduler'); expect(names).toContain('ytDlp'); expect(names).toContain('diskSpace'); expect(names).toContain('recentErrors'); }); }); });