381 lines
11 KiB
TypeScript
381 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for QueueManager
|
|
*
|
|
* Tests core queue operations, status management, and pub/sub functionality.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { QueueManager } from '$lib/server/queue/QueueManager';
|
|
|
|
describe('QueueManager', () => {
|
|
let queueManager: QueueManager;
|
|
|
|
beforeEach(() => {
|
|
// Create fresh instance for each test
|
|
queueManager = new QueueManager();
|
|
});
|
|
|
|
describe('enqueue', () => {
|
|
it('should enqueue items with unique IDs', () => {
|
|
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
|
|
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
|
|
|
|
expect(item1.id).toBeTruthy();
|
|
expect(item2.id).toBeTruthy();
|
|
expect(item1.id).not.toBe(item2.id);
|
|
});
|
|
|
|
it('should create items with pending status', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
expect(item.status).toBe('pending');
|
|
expect(item.enqueuedAt).toBeTruthy();
|
|
expect(item.logs).toEqual([]);
|
|
expect(item.progressEvents).toEqual([]);
|
|
expect(item.retryCount).toBe(0);
|
|
expect(item.maxRetries).toBe(3);
|
|
});
|
|
|
|
it('should notify subscribers when enqueueing', () => {
|
|
const callback = vi.fn();
|
|
queueManager.subscribe(callback);
|
|
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
expect(callback).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
itemId: item.id,
|
|
status: 'pending'
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('dequeue', () => {
|
|
it('should dequeue oldest pending item first (FIFO)', () => {
|
|
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
|
|
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
|
|
|
|
const dequeued1 = queueManager.dequeue();
|
|
expect(dequeued1?.id).toBe(item1.id);
|
|
|
|
const dequeued2 = queueManager.dequeue();
|
|
expect(dequeued2?.id).toBe(item2.id);
|
|
});
|
|
|
|
it('should return null when queue is empty', () => {
|
|
const item = queueManager.dequeue();
|
|
expect(item).toBeNull();
|
|
});
|
|
|
|
it('should mark dequeued item as in_progress', () => {
|
|
const enqueuedItem = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
const dequeuedItem = queueManager.dequeue();
|
|
|
|
expect(dequeuedItem?.status).toBe('in_progress');
|
|
expect(dequeuedItem?.currentPhase).toBe('extraction');
|
|
expect(dequeuedItem?.startedAt).toBeTruthy();
|
|
});
|
|
|
|
it('should skip non-pending items', () => {
|
|
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
|
|
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
|
|
|
|
// Dequeue first item
|
|
queueManager.dequeue();
|
|
|
|
// Second item should be next
|
|
const dequeued = queueManager.dequeue();
|
|
expect(dequeued?.id).toBe(item2.id);
|
|
});
|
|
});
|
|
|
|
describe('updateStatus', () => {
|
|
it('should update item status', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
queueManager.updateStatus(item.id, 'in_progress', { phase: 'parsing' });
|
|
|
|
const updated = queueManager.get(item.id);
|
|
expect(updated?.status).toBe('in_progress');
|
|
expect(updated?.currentPhase).toBe('parsing');
|
|
});
|
|
|
|
it('should set completedAt for terminal statuses', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
queueManager.updateStatus(item.id, 'success');
|
|
|
|
const updated = queueManager.get(item.id);
|
|
expect(updated?.completedAt).toBeTruthy();
|
|
});
|
|
|
|
it('should merge additional data into item', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
queueManager.updateStatus(item.id, 'success', {
|
|
recipe: { name: 'Test Recipe' },
|
|
tandoorRecipeId: 123
|
|
});
|
|
|
|
const updated = queueManager.get(item.id);
|
|
expect(updated?.recipe).toEqual({ name: 'Test Recipe' });
|
|
expect(updated?.tandoorRecipeId).toBe(123);
|
|
});
|
|
|
|
it('should handle error data', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
const errorData = {
|
|
error: {
|
|
phase: 'extraction' as const,
|
|
message: 'Failed to load page',
|
|
recoverable: true,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
};
|
|
|
|
queueManager.updateStatus(item.id, 'unhealthy', errorData);
|
|
|
|
const updated = queueManager.get(item.id);
|
|
expect(updated?.error).toEqual(errorData.error);
|
|
});
|
|
});
|
|
|
|
describe('addProgressEvent', () => {
|
|
it('should add progress events to item', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
const event = {
|
|
type: 'status',
|
|
message: 'Extracting...',
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
queueManager.addProgressEvent(item.id, event);
|
|
|
|
const updated = queueManager.get(item.id);
|
|
expect(updated?.progressEvents).toHaveLength(1);
|
|
expect(updated?.progressEvents[0]).toEqual(event);
|
|
});
|
|
|
|
it('should add event message to logs', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
queueManager.addProgressEvent(item.id, {
|
|
type: 'status',
|
|
message: 'Test message',
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
const updated = queueManager.get(item.id);
|
|
expect(updated?.logs).toContain('Test message');
|
|
});
|
|
|
|
it('should notify subscribers with event data', () => {
|
|
const callback = vi.fn();
|
|
queueManager.subscribe(callback);
|
|
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
callback.mockClear(); // Clear enqueue notification
|
|
|
|
const event = { type: 'status', message: 'Test', timestamp: new Date().toISOString() };
|
|
queueManager.addProgressEvent(item.id, event);
|
|
|
|
expect(callback).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
itemId: item.id,
|
|
data: { event }
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('remove', () => {
|
|
it('should remove items by ID', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
const removed = queueManager.remove(item.id);
|
|
|
|
expect(removed).toBe(true);
|
|
expect(queueManager.get(item.id)).toBeUndefined();
|
|
});
|
|
|
|
it('should return false for non-existent items', () => {
|
|
const removed = queueManager.remove('non-existent-id');
|
|
expect(removed).toBe(false);
|
|
});
|
|
|
|
it('should notify subscribers when removing', () => {
|
|
const callback = vi.fn();
|
|
queueManager.subscribe(callback);
|
|
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
callback.mockClear();
|
|
|
|
queueManager.remove(item.id);
|
|
|
|
expect(callback).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
itemId: item.id,
|
|
data: { removed: true }
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('retry', () => {
|
|
it('should retry failed items', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
queueManager.updateStatus(item.id, 'error');
|
|
|
|
const retried = queueManager.retry(item.id);
|
|
|
|
expect(retried).toBe(true);
|
|
|
|
const updated = queueManager.get(item.id);
|
|
expect(updated?.status).toBe('pending');
|
|
expect(updated?.retryCount).toBe(1);
|
|
expect(updated?.error).toBeUndefined();
|
|
expect(updated?.currentPhase).toBeUndefined();
|
|
});
|
|
|
|
it('should not retry items in progress', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
queueManager.updateStatus(item.id, 'in_progress');
|
|
|
|
const retried = queueManager.retry(item.id);
|
|
|
|
expect(retried).toBe(false);
|
|
expect(queueManager.get(item.id)?.status).toBe('in_progress');
|
|
});
|
|
|
|
it('should increment retry count', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
queueManager.updateStatus(item.id, 'error');
|
|
|
|
queueManager.retry(item.id);
|
|
queueManager.retry(item.id);
|
|
|
|
expect(queueManager.get(item.id)?.retryCount).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('getAll', () => {
|
|
it('should return all queue items', () => {
|
|
queueManager.enqueue('https://instagram.com/p/test1');
|
|
queueManager.enqueue('https://instagram.com/p/test2');
|
|
queueManager.enqueue('https://instagram.com/p/test3');
|
|
|
|
const items = queueManager.getAll();
|
|
|
|
expect(items).toHaveLength(3);
|
|
});
|
|
|
|
it('should return empty array when queue is empty', () => {
|
|
const items = queueManager.getAll();
|
|
expect(items).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('get', () => {
|
|
it('should return item by ID', () => {
|
|
const item = queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
const retrieved = queueManager.get(item.id);
|
|
|
|
expect(retrieved?.id).toBe(item.id);
|
|
expect(retrieved?.url).toBe(item.url);
|
|
});
|
|
|
|
it('should return undefined for non-existent ID', () => {
|
|
const item = queueManager.get('non-existent-id');
|
|
expect(item).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('subscribe', () => {
|
|
it('should notify subscribers of updates', () => {
|
|
const callback = vi.fn();
|
|
queueManager.subscribe(callback);
|
|
|
|
queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return unsubscribe function', () => {
|
|
const callback = vi.fn();
|
|
const unsubscribe = queueManager.subscribe(callback);
|
|
|
|
queueManager.enqueue('https://instagram.com/p/test1');
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
|
|
unsubscribe();
|
|
callback.mockClear();
|
|
|
|
queueManager.enqueue('https://instagram.com/p/test2');
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle subscriber errors gracefully', () => {
|
|
const goodCallback = vi.fn();
|
|
const badCallback = vi.fn(() => {
|
|
throw new Error('Subscriber error');
|
|
});
|
|
|
|
queueManager.subscribe(goodCallback);
|
|
queueManager.subscribe(badCallback);
|
|
|
|
// Should not throw despite bad callback
|
|
expect(() => {
|
|
queueManager.enqueue('https://instagram.com/p/test');
|
|
}).not.toThrow();
|
|
|
|
// Good callback should still be called
|
|
expect(goodCallback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should support multiple subscribers', () => {
|
|
const callback1 = vi.fn();
|
|
const callback2 = vi.fn();
|
|
const callback3 = vi.fn();
|
|
|
|
queueManager.subscribe(callback1);
|
|
queueManager.subscribe(callback2);
|
|
queueManager.subscribe(callback3);
|
|
|
|
queueManager.enqueue('https://instagram.com/p/test');
|
|
|
|
expect(callback1).toHaveBeenCalled();
|
|
expect(callback2).toHaveBeenCalled();
|
|
expect(callback3).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('deduplication', () => {
|
|
it('should return existing item when enqueueing duplicate URL', () => {
|
|
const url = 'https://instagram.com/p/ABC123';
|
|
const firstItem = queueManager.enqueue(url);
|
|
const secondItem = queueManager.enqueue(url);
|
|
|
|
expect(secondItem.id).toBe(firstItem.id);
|
|
expect(queueManager.getAll()).toHaveLength(1);
|
|
});
|
|
|
|
it('should find item by URL', () => {
|
|
const url = 'https://instagram.com/p/TEST123';
|
|
const item = queueManager.enqueue(url);
|
|
|
|
const found = queueManager.findByUrl(url);
|
|
expect(found?.id).toBe(item.id);
|
|
});
|
|
|
|
it('should return undefined when URL not found', () => {
|
|
const found = queueManager.findByUrl('https://instagram.com/p/NOTFOUND');
|
|
expect(found).toBeUndefined();
|
|
});
|
|
});
|
|
});
|