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 { createChannel } from '../db/repositories/channel-repository'; import { createContentItem, getContentItemById, } from '../db/repositories/content-repository'; import { getQueueItemById, updateQueueItemStatus, createQueueItem, countQueueItemsByStatus, } from '../db/repositories/queue-repository'; import { getHistoryEvents } from '../db/repositories/history-repository'; import { QueueService } from '../services/queue'; import type { ContentItem, Channel } from '../types/index'; // ── Test Helpers ── let tmpDir: string; let db: Awaited>; let testChannel: Channel; let contentItems: ContentItem[]; /** Create a mock DownloadService with a controllable downloadItem. */ function createMockDownloadService() { return { downloadItem: vi.fn().mockResolvedValue(undefined), }; } async function setupDb(): Promise { tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-qs-test-')); const dbPath = join(tmpDir, 'test.db'); db = await initDatabaseAsync(dbPath); await runMigrations(dbPath); testChannel = await createChannel(db, { name: 'Queue Service Test Channel', platform: 'youtube', platformId: 'UC_qs_test', url: 'https://www.youtube.com/channel/UC_qs_test', imageUrl: null, formatProfileId: null, monitoringEnabled: true, checkInterval: 360, metadata: null, }); // Pre-create a batch of content items for tests contentItems = []; for (let i = 1; i <= 6; i++) { const item = await createContentItem(db, { channelId: testChannel.id, title: `QS Test Video ${i}`, platformContentId: `vid_qs_${i}`, url: `https://www.youtube.com/watch?v=qs${i}`, contentType: 'video', duration: 300 + i * 60, status: 'monitored', }); contentItems.push(item!); } } function cleanup(): void { closeDatabase(); try { if (tmpDir && existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } } catch { // Windows cleanup best-effort } } /** Wait for async queue processing to settle. */ async function tick(ms = 50): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } // ── Tests ── describe('QueueService', () => { let mockDownloadService: ReturnType; beforeEach(async () => { await setupDb(); mockDownloadService = createMockDownloadService(); }); afterEach(() => { cleanup(); }); // ── Enqueue ── describe('enqueue', () => { it('creates queue item with pending status', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); // concurrency=0 to prevent auto-processing qs.stop(); const queueItem = await qs.enqueue(contentItems[0].id); expect(queueItem).toBeDefined(); expect(queueItem.contentItemId).toBe(contentItems[0].id); expect(queueItem.status).toBe('pending'); expect(queueItem.priority).toBe(0); expect(queueItem.attempts).toBe(0); }); it('updates content status to queued', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); const updated = await getContentItemById(db, contentItems[0].id); expect(updated!.status).toBe('queued'); }); it('records grabbed history event', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); const { items } = await getHistoryEvents(db); expect(items.length).toBe(1); expect(items[0].eventType).toBe('grabbed'); expect(items[0].contentItemId).toBe(contentItems[0].id); expect(items[0].channelId).toBe(testChannel.id); expect(items[0].status).toBe('pending'); }); it('respects priority parameter', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); const queueItem = await qs.enqueue(contentItems[0].id, 5); expect(queueItem.priority).toBe(5); }); it('throws on duplicate enqueue for pending item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await expect(qs.enqueue(contentItems[0].id)).rejects.toThrow( /already in the queue/ ); }); it('throws for non-existent content item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await expect(qs.enqueue(99999)).rejects.toThrow(/not found/); }); }); // ── Process ── describe('processItem', () => { it('transitions item through downloading to completed', async () => { const qs = new QueueService(db, mockDownloadService as any, 1); await qs.enqueue(contentItems[0].id); await tick(100); const queueItem = await getQueueItemById(db, 1); expect(queueItem!.status).toBe('completed'); expect(queueItem!.completedAt).not.toBeNull(); // Verify downloadItem was called expect(mockDownloadService.downloadItem).toHaveBeenCalledOnce(); const [calledContentItem, calledChannel] = mockDownloadService.downloadItem.mock.calls[0]; expect(calledContentItem.id).toBe(contentItems[0].id); expect(calledChannel.id).toBe(testChannel.id); }); it('records downloaded history event on success', async () => { const qs = new QueueService(db, mockDownloadService as any, 1); await qs.enqueue(contentItems[0].id); await tick(100); const { items } = await getHistoryEvents(db); const downloadedEvent = items.find((e) => e.eventType === 'downloaded'); expect(downloadedEvent).toBeDefined(); expect(downloadedEvent!.contentItemId).toBe(contentItems[0].id); expect(downloadedEvent!.status).toBe('completed'); }); it('retries on failure — sets status to pending with incremented attempts', async () => { // Only fail once, then stop to prevent retry loop let callCount = 0; mockDownloadService.downloadItem.mockImplementation(() => { callCount++; if (callCount === 1) { return Promise.reject(new Error('network timeout')); } // On retry, return a deferred that never resolves so we can inspect state return new Promise(() => {}); }); const qs = new QueueService(db, mockDownloadService as any, 1); await qs.enqueue(contentItems[0].id); // Wait for first attempt to fail and item to be reset to pending, // then the retry attempt starts (downloading) await tick(150); qs.stop(); // After the first failure, the item was reset to pending, then picked up again // Since we stopped, let's check the call count expect(callCount).toBeGreaterThanOrEqual(1); // After first failure, attempts should have been incremented to 1 // The item may have been picked up again (status=downloading), so check via history const { items } = await getHistoryEvents(db); const failedEvents = items.filter((e) => e.eventType === 'failed'); expect(failedEvents.length).toBeGreaterThanOrEqual(1); expect(failedEvents[0].details).toHaveProperty('error', 'network timeout'); expect(failedEvents[0].details).toHaveProperty('attempt', 1); }); it('records failed history event with error details', async () => { // Use a deferred to control exactly when the download completes let rejectFn: (err: Error) => void; mockDownloadService.downloadItem.mockImplementationOnce(() => { return new Promise((_, reject) => { rejectFn = reject; }); }); const qs = new QueueService(db, mockDownloadService as any, 1); qs.stop(); await qs.enqueue(contentItems[0].id); qs.start(); await tick(50); // Now reject the download — this triggers the failure path rejectFn!(new Error('network timeout')); await tick(50); qs.stop(); const { items } = await getHistoryEvents(db); const failedEvent = items.find((e) => e.eventType === 'failed'); expect(failedEvent).toBeDefined(); expect(failedEvent!.details).toHaveProperty('error', 'network timeout'); expect(failedEvent!.details).toHaveProperty('attempt', 1); expect(failedEvent!.details).toHaveProperty('exhausted', false); }); it('marks as failed when max attempts exhausted', async () => { mockDownloadService.downloadItem.mockRejectedValue(new Error('permanent failure')); const qs = new QueueService(db, mockDownloadService as any, 1); qs.stop(); // Enqueue, then manually set attempts to maxAttempts - 1 await qs.enqueue(contentItems[0].id); await updateQueueItemStatus(db, 1, 'pending', { attempts: 2 }); // maxAttempts defaults to 3 qs.start(); await tick(100); qs.stop(); const queueItem = await getQueueItemById(db, 1); expect(queueItem!.status).toBe('failed'); expect(queueItem!.attempts).toBe(3); // Content status should be set to failed const contentItem = await getContentItemById(db, contentItems[0].id); expect(contentItem!.status).toBe('failed'); // History event should indicate exhaustion const { items } = await getHistoryEvents(db); const failedEvent = items.find( (e) => e.eventType === 'failed' && (e.details as any)?.exhausted === true ); expect(failedEvent).toBeDefined(); }); }); // ── Concurrency ── describe('concurrency', () => { it('limits simultaneous downloads to concurrency value', async () => { // Track how many downloads are running simultaneously let concurrentCount = 0; let maxConcurrentCount = 0; const deferreds: Array<{ resolve: () => void; reject: (err: Error) => void }> = []; mockDownloadService.downloadItem.mockImplementation(() => { return new Promise((resolve, reject) => { concurrentCount++; maxConcurrentCount = Math.max(maxConcurrentCount, concurrentCount); deferreds.push({ resolve: () => { concurrentCount--; resolve(); }, reject: (err: Error) => { concurrentCount--; reject(err); }, }); }); }); const qs = new QueueService(db, mockDownloadService as any, 2); // Enqueue 4 items await qs.enqueue(contentItems[0].id); await qs.enqueue(contentItems[1].id); await qs.enqueue(contentItems[2].id); await qs.enqueue(contentItems[3].id); // Wait for processNext to fire await tick(100); // At most 2 should be downloading expect(maxConcurrentCount).toBe(2); expect(concurrentCount).toBe(2); // Resolve first two downloads deferreds[0].resolve(); await tick(50); deferreds[1].resolve(); await tick(150); // Remaining items should have been picked up and completed or still in-flight // Resolve any remaining deferreds for (let i = 2; i < deferreds.length; i++) { deferreds[i].resolve(); await tick(50); } await tick(100); // All 4 should be completed const counts = await countQueueItemsByStatus(db); expect(counts.completed).toBe(4); expect(counts.pending).toBe(0); // Most important: never exceeded concurrency limit of 2 expect(maxConcurrentCount).toBe(2); }); }); // ── Retry ── describe('retryItem', () => { it('resets failed item to pending and triggers processing', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); // Create and manually fail a queue item await qs.enqueue(contentItems[0].id); await updateQueueItemStatus(db, 1, 'failed', { attempts: 1, error: 'test error' }); const retried = await qs.retryItem(1); expect(retried.status).toBe('pending'); expect(retried.error).toBeNull(); // Content status should be reset to queued const contentItem = await getContentItemById(db, contentItems[0].id); expect(contentItem!.status).toBe('queued'); }); it('throws for non-failed item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await expect(qs.retryItem(1)).rejects.toThrow(/expected 'failed'/); }); it('throws when attempts exhausted', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await updateQueueItemStatus(db, 1, 'failed', { attempts: 3 }); // maxAttempts defaults to 3 await expect(qs.retryItem(1)).rejects.toThrow(/attempts.*>=.*maxAttempts/); }); it('throws for non-existent item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); await expect(qs.retryItem(99999)).rejects.toThrow(/not found/); }); }); // ── Cancel ── describe('cancelItem', () => { it('cancels a pending item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); const cancelled = await qs.cancelItem(1); expect(cancelled.status).toBe('cancelled'); }); it('cancels a failed item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await updateQueueItemStatus(db, 1, 'failed', { attempts: 1, error: 'err' }); const cancelled = await qs.cancelItem(1); expect(cancelled.status).toBe('cancelled'); }); it('throws for downloading item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await updateQueueItemStatus(db, 1, 'downloading'); await expect(qs.cancelItem(1)).rejects.toThrow(/must be 'pending' or 'failed'/); }); it('throws for completed item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await updateQueueItemStatus(db, 1, 'completed'); await expect(qs.cancelItem(1)).rejects.toThrow(/must be 'pending' or 'failed'/); }); it('throws for non-existent item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); await expect(qs.cancelItem(99999)).rejects.toThrow(/not found/); }); }); // ── Restart Recovery ── describe('recoverOnStartup', () => { it('resets downloading items to pending', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); // Directly insert items in 'downloading' status (simulating a crash) await createQueueItem(db, { contentItemId: contentItems[0].id }); await updateQueueItemStatus(db, 1, 'downloading', { startedAt: '2026-01-01T00:00:00Z' }); await createQueueItem(db, { contentItemId: contentItems[1].id }); await updateQueueItemStatus(db, 2, 'downloading', { startedAt: '2026-01-01T00:00:00Z' }); // Also create one pending item — should not be affected await createQueueItem(db, { contentItemId: contentItems[2].id }); const recovered = await qs.recoverOnStartup(); expect(recovered).toBe(2); const item1 = await getQueueItemById(db, 1); expect(item1!.status).toBe('pending'); expect(item1!.startedAt).toBeNull(); const item2 = await getQueueItemById(db, 2); expect(item2!.status).toBe('pending'); // Pending item should remain unchanged const item3 = await getQueueItemById(db, 3); expect(item3!.status).toBe('pending'); }); it('returns 0 when no items are stuck', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); const recovered = await qs.recoverOnStartup(); expect(recovered).toBe(0); }); }); // ── Stop / Start ── describe('stop/start', () => { it('stop prevents new items from being picked up', async () => { const qs = new QueueService(db, mockDownloadService as any, 2); qs.stop(); await qs.enqueue(contentItems[0].id); await tick(100); // Item should still be pending — processNext is a no-op while stopped const item = await getQueueItemById(db, 1); expect(item!.status).toBe('pending'); expect(mockDownloadService.downloadItem).not.toHaveBeenCalled(); }); it('start resumes processing', async () => { const qs = new QueueService(db, mockDownloadService as any, 2); qs.stop(); await qs.enqueue(contentItems[0].id); await tick(50); // Verify still pending let item = await getQueueItemById(db, 1); expect(item!.status).toBe('pending'); // Resume qs.start(); await tick(100); item = await getQueueItemById(db, 1); expect(item!.status).toBe('completed'); expect(mockDownloadService.downloadItem).toHaveBeenCalledOnce(); }); }); // ── Pause ── describe('pauseItem', () => { it('pauses a pending item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); const paused = await qs.pauseItem(1); expect(paused.status).toBe('paused'); }); it('pauses a downloading item by aborting the active download', async () => { // Use a deferred so we can control when the download completes let rejectFn: (err: Error) => void; mockDownloadService.downloadItem.mockImplementationOnce(() => { return new Promise((_resolve, reject) => { rejectFn = reject; }); }); const qs = new QueueService(db, mockDownloadService as any, 1); await qs.enqueue(contentItems[0].id); await tick(50); // Let it transition to downloading // Item should be downloading let item = await getQueueItemById(db, 1); expect(item!.status).toBe('downloading'); // Pause it — this should abort the download const paused = await qs.pauseItem(1); expect(paused.status).toBe('paused'); // Simulate the abort rejection (in real code, the AbortController signal kills yt-dlp) rejectFn!(new Error('aborted')); await tick(50); // Item should remain paused (not retried as failed) item = await getQueueItemById(db, 1); expect(item!.status).toBe('paused'); }); it('throws for completed item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await updateQueueItemStatus(db, 1, 'completed'); await expect(qs.pauseItem(1)).rejects.toThrow(/Cannot pause/); }); it('throws for cancelled item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await updateQueueItemStatus(db, 1, 'cancelled'); await expect(qs.pauseItem(1)).rejects.toThrow(/Cannot pause/); }); it('throws for non-existent item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); await expect(qs.pauseItem(99999)).rejects.toThrow(/not found/); }); }); // ── Resume ── describe('resumeItem', () => { it('resumes a paused item back to pending', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await qs.pauseItem(1); const resumed = await qs.resumeItem(1); expect(resumed.status).toBe('pending'); expect(resumed.error).toBeNull(); // Content status should be reset to queued const contentItem = await getContentItemById(db, contentItems[0].id); expect(contentItem!.status).toBe('queued'); }); it('throws for non-paused item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await expect(qs.resumeItem(1)).rejects.toThrow(/expected 'paused'/); }); it('throws for non-existent item', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); await expect(qs.resumeItem(99999)).rejects.toThrow(/not found/); }); it('triggers processNext after resume', async () => { // After resuming, the item should get picked up and processed const qs = new QueueService(db, mockDownloadService as any, 1); // Enqueue and pause qs.stop(); await qs.enqueue(contentItems[0].id); await qs.pauseItem(1); // Resume — processNext should fire and download qs.start(); await qs.resumeItem(1); await tick(100); const item = await getQueueItemById(db, 1); expect(item!.status).toBe('completed'); expect(mockDownloadService.downloadItem).toHaveBeenCalledOnce(); }); }); // ── getState ── describe('getState', () => { it('returns correct counts by status', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); qs.stop(); await qs.enqueue(contentItems[0].id); await qs.enqueue(contentItems[1].id); await qs.enqueue(contentItems[2].id); // Manually set some statuses await updateQueueItemStatus(db, 2, 'completed'); await updateQueueItemStatus(db, 3, 'failed'); const state = await qs.getState(); expect(state.pending).toBe(1); expect(state.completed).toBe(1); expect(state.failed).toBe(1); expect(state.downloading).toBe(0); expect(state.cancelled).toBe(0); expect(state.paused).toBe(0); }); it('returns all zeros when queue is empty', async () => { const qs = new QueueService(db, mockDownloadService as any, 0); const state = await qs.getState(); expect(state).toEqual({ pending: 0, downloading: 0, completed: 0, failed: 0, cancelled: 0, paused: 0, }); }); }); // ── Integration: full lifecycle ── describe('full lifecycle', () => { it('enqueue → process → complete lifecycle works end-to-end', async () => { const qs = new QueueService(db, mockDownloadService as any, 2); // Enqueue two items const q1 = await qs.enqueue(contentItems[0].id); const q2 = await qs.enqueue(contentItems[1].id, 5); // Wait for processing await tick(150); // Both should be completed const item1 = await getQueueItemById(db, q1.id); const item2 = await getQueueItemById(db, q2.id); expect(item1!.status).toBe('completed'); expect(item2!.status).toBe('completed'); // Download service should have been called twice expect(mockDownloadService.downloadItem).toHaveBeenCalledTimes(2); // Should have history events: 2 grabbed + 2 downloaded const { items: history } = await getHistoryEvents(db); const grabbed = history.filter((e) => e.eventType === 'grabbed'); const downloaded = history.filter((e) => e.eventType === 'downloaded'); expect(grabbed.length).toBe(2); expect(downloaded.length).toBe(2); // State should reflect completed const state = await qs.getState(); expect(state.completed).toBe(2); expect(state.pending).toBe(0); }); }); });