import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { type FastifyInstance } from 'fastify'; import { initDatabaseAsync, closeDatabase } from '../db/index'; import { runMigrations } from '../db/migrate'; import { buildServer } from '../server/index'; import { systemConfig, contentItems } from '../db/schema/index'; import { eq, sql } from 'drizzle-orm'; import { type LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; import { MissingFileScanner } from '../services/missing-file-scanner'; describe('Missing Scan API', () => { let server: FastifyInstance; let db: LibSQLDatabase; let apiKey: string; let tmpDir: string; beforeAll(async () => { tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-missing-scan-')); const dbPath = join(tmpDir, 'test.db'); db = await initDatabaseAsync(dbPath); await runMigrations(dbPath); server = await buildServer({ db }); // Attach missing file scanner const scanner = new MissingFileScanner(db); (server as { missingFileScanner: MissingFileScanner | null }).missingFileScanner = scanner; await server.ready(); // Read API key from database const rows = await db .select() .from(systemConfig) .where(eq(systemConfig.key, 'api_key')) .limit(1); apiKey = rows[0]?.value ?? ''; expect(apiKey).toBeTruthy(); }); afterAll(async () => { await server.close(); closeDatabase(); rmSync(tmpDir, { recursive: true, force: true }); }); // ── Helper to insert a content item ── async function insertContentItem(overrides: { status?: string; filePath?: string | null; title?: string; url?: string; } = {}) { const uid = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const result = await db .insert(contentItems) .values({ title: overrides.title ?? 'Test Video', url: overrides.url ?? `https://youtube.com/watch?v=${uid}`, platformContentId: uid, platform: 'youtube', contentType: 'video', status: overrides.status ?? 'downloaded', monitored: true, filePath: overrides.filePath ?? null, }) .returning(); return result[0]; } // ── POST /api/v1/system/missing-scan ── describe('POST /api/v1/system/missing-scan', () => { it('should trigger a scan and return results', async () => { const response = await server.inject({ method: 'POST', url: '/api/v1/system/missing-scan', headers: { 'x-api-key': apiKey }, }); expect(response.statusCode).toBe(200); const body = response.json(); expect(body.success).toBe(true); expect(body.data).toHaveProperty('checked'); expect(body.data).toHaveProperty('missing'); expect(body.data).toHaveProperty('duration'); expect(typeof body.data.checked).toBe('number'); expect(typeof body.data.missing).toBe('number'); }); it('should detect a missing file', async () => { // Insert a content item with a filePath that does not exist on disk const fakePath = join(tmpDir, 'nonexistent-file.mp4'); await insertContentItem({ status: 'downloaded', filePath: fakePath, url: `https://youtube.com/watch?v=missing-${Date.now()}`, }); const response = await server.inject({ method: 'POST', url: '/api/v1/system/missing-scan', headers: { 'x-api-key': apiKey }, }); expect(response.statusCode).toBe(200); const body = response.json(); expect(body.success).toBe(true); expect(body.data.missing).toBeGreaterThanOrEqual(1); }); it('should not flag files that exist on disk', async () => { // Create a real file const realPath = join(tmpDir, 'existing-file.mp4'); writeFileSync(realPath, 'fake content'); await insertContentItem({ status: 'downloaded', filePath: realPath, url: `https://youtube.com/watch?v=exists-${Date.now()}`, }); const response = await server.inject({ method: 'POST', url: '/api/v1/system/missing-scan', headers: { 'x-api-key': apiKey }, }); expect(response.statusCode).toBe(200); const body = response.json(); expect(body.success).toBe(true); // The existing file should not be counted as missing // (but previously inserted missing files may still be counted) }); }); // ── GET /api/v1/system/missing-scan/status ── describe('GET /api/v1/system/missing-scan/status', () => { it('should return null when no scan has been run', async () => { // Use a fresh scanner with a fresh DB to test no-prior-scan state // Since we already ran scans above, we check that status returns data const response = await server.inject({ method: 'GET', url: '/api/v1/system/missing-scan/status', headers: { 'x-api-key': apiKey }, }); expect(response.statusCode).toBe(200); const body = response.json(); expect(body.success).toBe(true); // After previous tests, data should have lastRun and result if (body.data !== null) { expect(body.data).toHaveProperty('lastRun'); expect(body.data).toHaveProperty('result'); expect(body.data.result).toHaveProperty('checked'); expect(body.data.result).toHaveProperty('missing'); } }); }); // ── POST /api/v1/content/:id/requeue ── describe('POST /api/v1/content/:id/requeue', () => { it('should return 404 for a non-existent content item', async () => { const response = await server.inject({ method: 'POST', url: '/api/v1/content/99999/requeue', headers: { 'x-api-key': apiKey }, }); expect(response.statusCode).toBe(404); }); it('should return 400 if content item is not in missing status', async () => { const item = await insertContentItem({ status: 'monitored', url: `https://youtube.com/watch?v=monitored-${Date.now()}`, }); const response = await server.inject({ method: 'POST', url: `/api/v1/content/${item.id}/requeue`, headers: { 'x-api-key': apiKey }, }); expect(response.statusCode).toBe(400); const body = response.json(); expect(body.message).toContain('monitored'); }); it('should requeue a missing content item', async () => { const item = await insertContentItem({ status: 'missing', filePath: join(tmpDir, 'deleted.mp4'), url: `https://youtube.com/watch?v=requeue-${Date.now()}`, }); // Need queueService for this to work — check if it returns 503 const response = await server.inject({ method: 'POST', url: `/api/v1/content/${item.id}/requeue`, headers: { 'x-api-key': apiKey }, }); // Without a queue service attached, we get 503 // With one, we'd get 201 if (response.statusCode === 503) { expect(response.json().message).toContain('Queue service'); } else { expect(response.statusCode).toBe(201); const body = response.json(); expect(body.success).toBe(true); } }); }); });