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 { createChannel } from '../db/repositories/channel-repository'; import { createContentItem } from '../db/repositories/content-repository'; import { updateQueueItemStatus } from '../db/repositories/queue-repository'; import { QueueService } from '../services/queue'; 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'; /** * End-to-end integration test exercising the full application flow: * channel CRUD → content listing → download enqueue → queue state → * history records → health check → system status. * * Uses a real SQLite database with migrations and Fastify inject() for * fast HTTP testing without binding ports. The download service is mocked * so yt-dlp is not required. */ describe('End-to-end flow', () => { let server: FastifyInstance; let db: LibSQLDatabase; let apiKey: string; let tmpDir: string; // IDs populated during test flow let channelId: number; let contentItemId: number; let queueItemId: number; // Mock download service — simulates successful downloads const mockDownloadService = { downloadItem: vi.fn().mockResolvedValue(undefined), }; beforeAll(async () => { // Create isolated temp database tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-e2e-')); const dbPath = join(tmpDir, 'test.db'); db = await initDatabaseAsync(dbPath); await runMigrations(dbPath); // Build server with real database server = await buildServer({ db }); // Attach a QueueService with mock download service so enqueue works const queueService = new QueueService(db, mockDownloadService as any, { concurrency: 1, }); // Stop auto-processing so we control when downloads run queueService.stop(); server.queueService = queueService; await server.ready(); // Read auto-generated 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 () => { server.queueService?.stop(); await server.close(); closeDatabase(); // Windows: SQLite WAL/SHM files may be locked briefly (K004) try { if (tmpDir && existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } } catch { // Temp dir cleanup is best-effort on Windows } }); // ── Step 1: Create a channel (via repository — bypasses yt-dlp resolution) ── describe('Step 1: Channel creation and retrieval', () => { it('creates a channel in the database', async () => { const channel = await createChannel(db, { name: 'E2E Test Channel', platform: 'youtube', platformId: 'UC_e2e_test_channel', url: 'https://www.youtube.com/channel/UC_e2e_test_channel', imageUrl: 'https://example.com/thumb.jpg', formatProfileId: null, monitoringEnabled: true, checkInterval: 360, metadata: null, }); expect(channel.id).toBeGreaterThan(0); channelId = channel.id; }); it('GET /api/v1/channel/:id returns the channel', async () => { const res = await server.inject({ method: 'GET', url: `/api/v1/channel/${channelId}`, headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.name).toBe('E2E Test Channel'); expect(body.platform).toBe('youtube'); expect(body.platformId).toBe('UC_e2e_test_channel'); }); it('GET /api/v1/channel lists channels including ours', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/channel', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const channels = res.json(); expect(Array.isArray(channels)).toBe(true); expect(channels.some((c: { id: number }) => c.id === channelId)).toBe(true); }); }); // ── Step 2: Create content item (via repository — simulates scheduler detection) ── describe('Step 2: Content creation and listing', () => { it('creates a content item for the channel', async () => { const item = await createContentItem(db, { channelId, title: 'E2E Test Video — How to Build a Media Server', platformContentId: 'e2e_test_video_001', url: 'https://www.youtube.com/watch?v=e2e_test_001', contentType: 'video', duration: 600, status: 'monitored', }); expect(item).not.toBeNull(); contentItemId = item!.id; }); it('GET /api/v1/content?channelId=:id shows the content item', async () => { const res = await server.inject({ method: 'GET', url: `/api/v1/content?channelId=${channelId}`, headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.length).toBeGreaterThanOrEqual(1); expect(body.data.some((c: { id: number }) => c.id === contentItemId)).toBe(true); }); it('GET /api/v1/channel/:id/content returns channel-specific content', async () => { const res = await server.inject({ method: 'GET', url: `/api/v1/channel/${channelId}/content`, headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.length).toBeGreaterThanOrEqual(1); const item = body.data.find((c: { id: number }) => c.id === contentItemId); expect(item).toBeDefined(); expect(item.title).toBe('E2E Test Video — How to Build a Media Server'); }); }); // ── Step 3: Enqueue download and check queue state ── describe('Step 3: Download enqueue and queue management', () => { it('POST /api/v1/download/:contentItemId enqueues the item', async () => { const res = await server.inject({ method: 'POST', url: `/api/v1/download/${contentItemId}`, headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(202); const body = res.json(); expect(body.success).toBe(true); expect(body.data).toHaveProperty('id'); expect(body.data.contentItemId).toBe(contentItemId); expect(body.data.status).toBe('pending'); queueItemId = body.data.id; }); it('GET /api/v1/queue shows the queued item', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/queue', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.length).toBeGreaterThanOrEqual(1); const item = body.data.find((q: { id: number }) => q.id === queueItemId); expect(item).toBeDefined(); expect(item.status).toBe('pending'); }); it('GET /api/v1/queue?status=pending filters correctly', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/queue?status=pending', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.every((q: { status: string }) => q.status === 'pending')).toBe(true); }); it('POST /api/v1/download/:contentItemId rejects duplicate enqueue', async () => { const res = await server.inject({ method: 'POST', url: `/api/v1/download/${contentItemId}`, headers: { 'x-api-key': apiKey }, }); // Content status is now 'queued', so this should return 409 expect(res.statusCode).toBe(409); }); }); // ── Step 4: Simulate download completion and verify history ── describe('Step 4: Download completion and history', () => { it('simulating download completion creates history records', async () => { // Manually transition the queue item to completed to simulate // what the QueueService would do after a successful download await updateQueueItemStatus(db, queueItemId, 'completed', { completedAt: new Date().toISOString(), }); // Verify queue item is now completed const queueRes = await server.inject({ method: 'GET', url: `/api/v1/queue/${queueItemId}`, headers: { 'x-api-key': apiKey }, }); expect(queueRes.statusCode).toBe(200); expect(queueRes.json().data.status).toBe('completed'); }); it('GET /api/v1/history shows history events', async () => { // The enqueue operation created a 'grabbed' history event const res = await server.inject({ method: 'GET', url: '/api/v1/history', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.length).toBeGreaterThanOrEqual(1); // At minimum, we should have a 'grabbed' event from enqueue const grabbedEvent = body.data.find( (e: { eventType: string; contentItemId: number | null }) => e.eventType === 'grabbed' && e.contentItemId === contentItemId ); expect(grabbedEvent).toBeDefined(); }); it('GET /api/v1/history?eventType=grabbed filters by event type', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/history?eventType=grabbed', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.every((e: { eventType: string }) => e.eventType === 'grabbed')).toBe(true); }); it('GET /api/v1/activity returns recent activity', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/activity', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.length).toBeGreaterThanOrEqual(1); }); }); // ── Step 5: Health and System Status ── describe('Step 5: Health and system status', () => { it('GET /ping returns ok (unauthenticated)', async () => { const res = await server.inject({ method: 'GET', url: '/ping', }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ status: 'ok' }); }); it('GET /api/v1/health returns healthy status', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/health', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.status).toBeDefined(); expect(body.components).toBeDefined(); expect(Array.isArray(body.components)).toBe(true); // Database component should be healthy const dbComponent = body.components.find( (c: { name: string }) => c.name === 'database' ); expect(dbComponent).toBeDefined(); expect(dbComponent.status).toBe('healthy'); }); it('GET /api/v1/system/status returns system information', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/status', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body).toHaveProperty('appName'); expect(body.appName).toBe('Tubearr'); expect(body).toHaveProperty('version'); expect(body).toHaveProperty('uptime'); expect(body).toHaveProperty('platform'); expect(body).toHaveProperty('nodeVersion'); expect(typeof body.uptime).toBe('number'); }); }); // ── Step 6: Error handling and edge cases ── describe('Step 6: Error handling', () => { it('returns 401 for missing API key on protected routes', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/system/status', }); expect(res.statusCode).toBe(401); }); it('returns 404 for unknown API routes', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/nonexistent-route', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(404); }); it('returns 404 for non-existent channel', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/channel/99999', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(404); }); it('returns 404 for non-existent content item download', async () => { const res = await server.inject({ method: 'POST', url: '/api/v1/download/99999', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(404); }); }); });