tubearr/src/__tests__/queue-service.test.ts
jlightner daf892edad feat: Add pause/resume buttons, paused status badge, and Paused filter…
- "src/frontend/src/pages/Queue.tsx"
- "src/frontend/src/api/hooks/useQueue.ts"
- "src/frontend/src/components/StatusBadge.tsx"

GSD-Task: S07/T04
2026-04-04 07:13:13 +00:00

718 lines
24 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 { 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<ReturnType<typeof initDatabaseAsync>>;
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<void> {
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<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
// ── Tests ──
describe('QueueService', () => {
let mockDownloadService: ReturnType<typeof createMockDownloadService>;
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<void>((_, 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<void>((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<void>((_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);
});
});
});