tubearr/src/__tests__/health-service.test.ts
John Lightner 4606dce553 feat: Tubearr — full project state through M006/S01
Migrated git root from W:/programming/Projects/ to W:/programming/Projects/Tubearr/.
Previous history preserved in Tubearr-full-backup.bundle at parent directory.

Completed milestones: M001 through M005
Active: M006/S02 (Add Channel UX)
2026-03-24 20:20:10 -05:00

326 lines
11 KiB
TypeScript

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<typeof import('node:fs/promises')>();
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<ReturnType<typeof initDatabaseAsync>>;
async function setupDb(): Promise<void> {
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>): 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');
});
});
});