- "src/frontend/src/pages/Queue.tsx" - "src/frontend/src/api/hooks/useQueue.ts" - "src/frontend/src/components/StatusBadge.tsx" GSD-Task: S07/T04
718 lines
24 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|