import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { mkdtempSync, rmSync, existsSync } 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 } from '../db/schema/index'; import { eq } from 'drizzle-orm'; import { type LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; import { createChannel } from '../db/repositories/channel-repository'; import { createContentItem, updateContentItem } from '../db/repositories/content-repository'; import { QueueService } from '../services/queue'; import type { DownloadService } from '../services/download'; import type { ContentItem, Channel } from '../types/index'; /** * Integration tests for the download trigger API endpoint. * * The download route now enqueues via QueueService instead of calling * DownloadService directly. It returns 202 Accepted with the queue item. */ describe('Download API', () => { let server: FastifyInstance; let db: LibSQLDatabase; let apiKey: string; let tmpDir: string; let testChannel: Channel; let queueService: QueueService; let mockDownloadService: { downloadItem: ReturnType; }; beforeAll(async () => { tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-dl-api-')); const dbPath = join(tmpDir, 'test.db'); db = await initDatabaseAsync(dbPath); await runMigrations(dbPath); server = await buildServer({ db }); // Create mock download service and queue service mockDownloadService = { downloadItem: vi.fn().mockResolvedValue(undefined), }; queueService = new QueueService( db, mockDownloadService as unknown as DownloadService, 2 ); // Stop auto-processing so tests stay deterministic queueService.stop(); (server as { downloadService: DownloadService | null }).downloadService = mockDownloadService as unknown as DownloadService; (server as { queueService: QueueService | null }).queueService = queueService; await server.ready(); // Read API key const rows = await db .select() .from(systemConfig) .where(eq(systemConfig.key, 'api_key')) .limit(1); apiKey = rows[0]?.value ?? ''; expect(apiKey).toBeTruthy(); // Create a test channel testChannel = await createChannel(db, { name: 'Download Test Channel', platform: 'youtube', platformId: 'UC_dl_test', url: 'https://www.youtube.com/channel/UC_dl_test', monitoringEnabled: true, checkInterval: 360, imageUrl: null, metadata: null, formatProfileId: null, }); }); afterAll(async () => { queueService.stop(); await server.close(); closeDatabase(); try { if (tmpDir && existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } } catch { // Temp dir cleanup is best-effort on Windows } }); // ── Helpers ── function authed(opts: Record) { return { ...opts, headers: { 'x-api-key': apiKey, ...(opts.headers as Record | undefined) }, }; } let contentCounter = 0; async function createTestContentItem( overrides: { status?: string; platformContentId?: string } = {} ): Promise { contentCounter++; const item = await createContentItem(db, { channelId: testChannel.id, title: 'Test Download Video', platformContentId: overrides.platformContentId ?? `vid_dl_${Date.now()}_${contentCounter}`, url: 'https://www.youtube.com/watch?v=test123', contentType: 'video', duration: 300, status: (overrides.status ?? 'monitored') as 'monitored', }); return item!; } // ── Auth gating ── describe('Authentication', () => { it('returns 401 when no API key is provided', async () => { const res = await server.inject({ method: 'POST', url: '/api/v1/download/1', }); expect(res.statusCode).toBe(401); }); }); // ── 404 handling ── describe('Not found', () => { it('returns 404 for non-existent content item', async () => { const res = await server.inject( authed({ method: 'POST', url: '/api/v1/download/99999' }) ); expect(res.statusCode).toBe(404); expect(res.json().message).toContain('99999'); }); it('returns 400 for non-numeric content item ID', async () => { const res = await server.inject( authed({ method: 'POST', url: '/api/v1/download/abc' }) ); expect(res.statusCode).toBe(400); }); }); // ── 409 Conflict ── describe('Conflict handling', () => { it('returns 409 when content item is already downloading', async () => { const item = await createTestContentItem(); await updateContentItem(db, item.id, { status: 'downloading' }); const res = await server.inject( authed({ method: 'POST', url: `/api/v1/download/${item.id}` }) ); expect(res.statusCode).toBe(409); expect(res.json().message).toContain('downloading'); }); it('returns 409 when content item is already downloaded', async () => { const item = await createTestContentItem(); await updateContentItem(db, item.id, { status: 'downloaded' }); const res = await server.inject( authed({ method: 'POST', url: `/api/v1/download/${item.id}` }) ); expect(res.statusCode).toBe(409); expect(res.json().message).toContain('downloaded'); }); it('returns 409 when content item is already queued', async () => { const item = await createTestContentItem(); // Enqueue once await queueService.enqueue(item.id); // Try to enqueue again via the download endpoint const res = await server.inject( authed({ method: 'POST', url: `/api/v1/download/${item.id}` }) ); expect(res.statusCode).toBe(409); expect(res.json().message).toContain('already in the queue'); }); }); // ── Successful enqueue ── describe('Successful enqueue via download endpoint', () => { it('returns 202 Accepted with queue item', async () => { const item = await createTestContentItem(); const res = await server.inject( authed({ method: 'POST', url: `/api/v1/download/${item.id}` }) ); expect(res.statusCode).toBe(202); const body = res.json(); expect(body.success).toBe(true); expect(body.data.contentItemId).toBe(item.id); expect(body.data.status).toBe('pending'); expect(body.data.id).toBeDefined(); }); it('re-allows enqueue of failed items', async () => { const item = await createTestContentItem(); await updateContentItem(db, item.id, { status: 'failed' }); const res = await server.inject( authed({ method: 'POST', url: `/api/v1/download/${item.id}` }) ); // Failed items can be re-enqueued expect(res.statusCode).toBe(202); const body = res.json(); expect(body.success).toBe(true); expect(body.data.status).toBe('pending'); }); }); });