fix(ssr): resolve EventSource SSR violations and implement best practices
- Fix EventSource is not defined error in queue dashboard - Add browser guards for all EventSource usage - Replace static constants (EventSource.OPEN/CLOSED) with numeric values - Fix setInterval SSR violation in LLM health indicator - Replace $effect anti-pattern with onMount in share page - Add comprehensive SvelteKit SSR best practices documentation - Add SSR audit and testing verification All changes follow SvelteKit best practices and are verified against official documentation. Production build succeeds with no SSR errors. Closes: FixEventSourceSSR See: docs/outcomes/FixEventSourceSSR.md
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* Integration tests for thumbnail URL validation in the complete extraction flow
|
||||
|
||||
518
src/tests/queue-api.spec.ts
Normal file
518
src/tests/queue-api.spec.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Integration tests for Queue API endpoints
|
||||
*
|
||||
* Tests the HTTP API routes for queue operations by directly invoking the handlers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { queueManager } from '$lib/server/queue/QueueManager';
|
||||
import { POST as queuePOST, GET as queueGET } from '../routes/api/queue/+server.js';
|
||||
import { GET as itemGET, DELETE as itemDELETE } from '../routes/api/queue/[id]/+server.js';
|
||||
import { POST as retryPOST } from '../routes/api/queue/[id]/retry/+server.js';
|
||||
|
||||
describe('Queue API Endpoints', () => {
|
||||
beforeEach(() => {
|
||||
// Clear queue between tests
|
||||
queueManager.getAll().forEach(item => queueManager.remove(item.id));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after tests
|
||||
queueManager.getAll().forEach(item => queueManager.remove(item.id));
|
||||
});
|
||||
|
||||
describe('POST /api/queue', () => {
|
||||
it('should enqueue valid Instagram URL', async () => {
|
||||
const request = new Request('http://localhost/api/queue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: 'https://instagram.com/p/ABC123'
|
||||
})
|
||||
});
|
||||
|
||||
const response = await queuePOST({ request } as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.id).toBeTruthy();
|
||||
expect(data.url).toBe('https://instagram.com/p/ABC123');
|
||||
expect(data.status).toBe('pending');
|
||||
expect(data.enqueuedAt).toBeTruthy();
|
||||
|
||||
// Verify item exists in queue
|
||||
const item = queueManager.get(data.id);
|
||||
expect(item).toBeTruthy();
|
||||
expect(item?.url).toBe('https://instagram.com/p/ABC123');
|
||||
});
|
||||
|
||||
it('should accept Instagram URLs with www', async () => {
|
||||
const request = new Request('http://localhost/api/queue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: 'https://www.instagram.com/p/XYZ789'
|
||||
})
|
||||
});
|
||||
|
||||
const response = await queuePOST({ request } as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.url).toBe('https://www.instagram.com/p/XYZ789');
|
||||
|
||||
// Verify item exists in queue
|
||||
const item = queueManager.get(data.id);
|
||||
expect(item).toBeTruthy();
|
||||
expect(item?.url).toBe('https://www.instagram.com/p/XYZ789');
|
||||
});
|
||||
|
||||
it('should reject invalid Instagram URL formats', async () => {
|
||||
const invalidUrls = [
|
||||
'https://facebook.com/post/123',
|
||||
'https://instagram.com/user/profile',
|
||||
'not-a-url',
|
||||
'https://other-site.com'
|
||||
];
|
||||
|
||||
for (const url of invalidUrls) {
|
||||
const request = new Request('http://localhost/api/queue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await queuePOST({ request } as any);
|
||||
// If we get here, check the response status
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Invalid Instagram URL format. Expected: https://instagram.com/p/{post-id}');
|
||||
} catch (err: any) {
|
||||
// SvelteKit's error() throws - check the error
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.body.message).toBe('Invalid Instagram URL format. Expected: https://instagram.com/p/{post-id}');
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no items were added to queue
|
||||
expect(queueManager.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject missing URL', async () => {
|
||||
const request = new Request('http://localhost/api/queue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await queuePOST({ request } as any);
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('URL is required and must be a string');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.body.message).toBe('URL is required and must be a string');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject non-JSON body', async () => {
|
||||
const request = new Request('http://localhost/api/queue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: 'not json'
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await queuePOST({ request } as any);
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Invalid JSON in request body');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.body.message).toBe('Invalid JSON in request body');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/queue', () => {
|
||||
it('should return empty list when no items', async () => {
|
||||
const url = new URL('http://localhost/api/queue');
|
||||
const request = new Request(url);
|
||||
|
||||
const response = await queueGET({ request, url } as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.items).toEqual([]);
|
||||
expect(data.total).toBe(0);
|
||||
expect(data.pagination.offset).toBe(0);
|
||||
expect(data.pagination.limit).toBe(50);
|
||||
});
|
||||
|
||||
it('should return queued items', async () => {
|
||||
// Add test items
|
||||
const item1 = queueManager.enqueue('https://instagram.com/p/TEST1');
|
||||
const item2 = queueManager.enqueue('https://instagram.com/p/TEST2');
|
||||
|
||||
const url = new URL('http://localhost/api/queue');
|
||||
const request = new Request(url);
|
||||
|
||||
const response = await queueGET({ request, url } as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.total).toBe(2);
|
||||
expect(data.items).toHaveLength(2);
|
||||
expect(data.items[0].url).toBe('https://instagram.com/p/TEST1');
|
||||
expect(data.items[1].url).toBe('https://instagram.com/p/TEST2');
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
// Add test items with different statuses
|
||||
const item1 = queueManager.enqueue('https://instagram.com/p/PENDING');
|
||||
const item2 = queueManager.enqueue('https://instagram.com/p/ERROR');
|
||||
|
||||
// Set one to error status
|
||||
queueManager.updateStatus(item2.id, 'error', { message: 'Test error' });
|
||||
|
||||
const url = new URL('http://localhost/api/queue?status=error');
|
||||
const request = new Request(url);
|
||||
|
||||
const response = await queueGET({ request, url } as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.total).toBe(1);
|
||||
expect(data.items).toHaveLength(1);
|
||||
expect(data.items[0].status).toBe('error');
|
||||
expect(data.items[0].url).toBe('https://instagram.com/p/ERROR');
|
||||
});
|
||||
|
||||
it('should handle pagination', async () => {
|
||||
// Add multiple test items
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
queueManager.enqueue(`https://instagram.com/p/TEST${i}`);
|
||||
}
|
||||
|
||||
const url = new URL('http://localhost/api/queue?limit=2&offset=1');
|
||||
const request = new Request(url);
|
||||
|
||||
const response = await queueGET({ request, url } as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.total).toBe(5);
|
||||
expect(data.items).toHaveLength(2);
|
||||
expect(data.pagination.offset).toBe(1);
|
||||
expect(data.pagination.limit).toBe(2);
|
||||
// Items are sorted by enqueued time (newest first), so with offset=1, limit=2 we get items 2-3 from the sorted list
|
||||
});
|
||||
|
||||
it('should validate query parameters', async () => {
|
||||
// Invalid status
|
||||
try {
|
||||
let url = new URL('http://localhost/api/queue?status=invalid');
|
||||
let request = new Request(url);
|
||||
let response = await queueGET({ request, url } as any);
|
||||
expect(response.status).toBe(400);
|
||||
let data = await response.json();
|
||||
expect(data.message).toBe('Invalid status filter. Must be one of: pending, in_progress, success, unhealthy, error');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.body.message).toBe('Invalid status filter. Must be one of: pending, in_progress, success, unhealthy, error');
|
||||
}
|
||||
|
||||
// Invalid limit (negative)
|
||||
try {
|
||||
let url = new URL('http://localhost/api/queue?limit=-1');
|
||||
let request = new Request(url);
|
||||
let response = await queueGET({ request, url } as any);
|
||||
expect(response.status).toBe(400);
|
||||
let data = await response.json();
|
||||
expect(data.message).toBe('Limit must be a positive integer');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.body.message).toBe('Limit must be a positive integer');
|
||||
}
|
||||
|
||||
// Invalid offset (negative)
|
||||
try {
|
||||
let url = new URL('http://localhost/api/queue?offset=-1');
|
||||
let request = new Request(url);
|
||||
let response = await queueGET({ request, url } as any);
|
||||
expect(response.status).toBe(400);
|
||||
let data = await response.json();
|
||||
expect(data.message).toBe('Offset must be a non-negative integer');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.body.message).toBe('Offset must be a non-negative integer');
|
||||
}
|
||||
|
||||
// Limit too large
|
||||
try {
|
||||
let url = new URL('http://localhost/api/queue?limit=999');
|
||||
let request = new Request(url);
|
||||
let response = await queueGET({ request, url } as any);
|
||||
expect(response.status).toBe(400);
|
||||
let data = await response.json();
|
||||
expect(data.message).toBe('Limit cannot exceed 200');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.body.message).toBe('Limit cannot exceed 200');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/queue/[id]', () => {
|
||||
it('should return queue item by ID', async () => {
|
||||
// Add test item
|
||||
const item = queueManager.enqueue('https://instagram.com/p/DETAIL');
|
||||
|
||||
const response = await itemGET({
|
||||
params: { id: item.id }
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.id).toBe(item.id);
|
||||
expect(data.url).toBe('https://instagram.com/p/DETAIL');
|
||||
expect(data.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent ID', async () => {
|
||||
const fakeId = '550e8400-e29b-41d4-a716-446655440000'; // Valid v4 UUID format but non-existent
|
||||
try {
|
||||
const response = await itemGET({
|
||||
params: { id: fakeId }
|
||||
} as any);
|
||||
expect(response.status).toBe(404);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Queue item not found');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.body.message).toBe('Queue item not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate ID format', async () => {
|
||||
try {
|
||||
const response = await itemGET({
|
||||
params: { id: 'invalid-id' }
|
||||
} as any);
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Invalid queue item ID format');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.body.message).toBe('Invalid queue item ID format');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/queue/[id]/retry', () => {
|
||||
it('should retry error item', async () => {
|
||||
// Add test item and set to error
|
||||
const item = queueManager.enqueue('https://instagram.com/p/RETRY');
|
||||
queueManager.updateStatus(item.id, 'error', { message: 'Test error' });
|
||||
|
||||
const request = new Request(`http://localhost/api/queue/${item.id}/retry`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const response = await retryPOST({
|
||||
request,
|
||||
params: { id: item.id }
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Queue item has been reset and will be reprocessed');
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
// Verify item status was reset
|
||||
const updatedItem = queueManager.get(item.id);
|
||||
expect(updatedItem?.status).toBe('pending');
|
||||
expect(updatedItem?.error).toBeUndefined(); // error field is cleared (undefined, not null)
|
||||
});
|
||||
|
||||
it('should retry unhealthy item', async () => {
|
||||
// Add test item and set to unhealthy
|
||||
const item = queueManager.enqueue('https://instagram.com/p/UNHEALTHY');
|
||||
queueManager.updateStatus(item.id, 'unhealthy', {
|
||||
phase: 'extraction',
|
||||
attempts: 3,
|
||||
lastAttempt: new Date(),
|
||||
message: 'Max retries exceeded'
|
||||
});
|
||||
|
||||
const request = new Request(`http://localhost/api/queue/${item.id}/retry`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const response = await retryPOST({
|
||||
request,
|
||||
params: { id: item.id }
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Queue item has been reset and will be reprocessed');
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
// Verify item status was reset
|
||||
const updatedItem = queueManager.get(item.id);
|
||||
expect(updatedItem?.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('should reject retry for non-retryable statuses', async () => {
|
||||
// Add test item (default status is 'pending')
|
||||
const item = queueManager.enqueue('https://instagram.com/p/PENDING');
|
||||
|
||||
const request = new Request(`http://localhost/api/queue/${item.id}/retry`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
// Item is pending (cannot retry)
|
||||
try {
|
||||
const response = await retryPOST({
|
||||
request,
|
||||
params: { id: item.id }
|
||||
} as any);
|
||||
expect(response.status).toBe(409);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe("Cannot retry item with status 'pending'. Only 'error' and 'unhealthy' items can be retried.");
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(409);
|
||||
expect(err.body.message).toBe("Cannot retry item with status 'pending'. Only 'error' and 'unhealthy' items can be retried.");
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent item', async () => {
|
||||
const fakeId = '550e8400-e29b-41d4-a716-446655440000'; // Valid v4 UUID format but non-existent
|
||||
const request = new Request(`http://localhost/api/queue/${fakeId}/retry`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await retryPOST({
|
||||
request,
|
||||
params: { id: fakeId }
|
||||
} as any);
|
||||
expect(response.status).toBe(404);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Queue item not found');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.body.message).toBe('Queue item not found');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/queue/[id]', () => {
|
||||
it('should delete queue item successfully', async () => {
|
||||
// Create an item
|
||||
const item = queueManager.enqueue('https://instagram.com/p/DELETE123');
|
||||
|
||||
// Mark it as success (completed)
|
||||
queueManager.updateStatus(item.id, 'success');
|
||||
|
||||
const request = new Request(`http://localhost/api/queue/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const response = await itemDELETE({
|
||||
request,
|
||||
params: { id: item.id }
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.message).toBe('Queue item removed successfully');
|
||||
|
||||
// Verify item no longer exists
|
||||
expect(queueManager.get(item.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent item', async () => {
|
||||
const fakeId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const request = new Request(`http://localhost/api/queue/${fakeId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await itemDELETE({
|
||||
request,
|
||||
params: { id: fakeId }
|
||||
} as any);
|
||||
expect(response.status).toBe(404);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Queue item not found');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.body.message).toBe('Queue item not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 409 for in-progress items', async () => {
|
||||
// Create an item and mark it as in progress
|
||||
const item = queueManager.enqueue('https://instagram.com/p/PROCESSING');
|
||||
queueManager.updateStatus(item.id, 'in_progress', { phase: 'extraction' });
|
||||
|
||||
const request = new Request(`http://localhost/api/queue/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await itemDELETE({
|
||||
request,
|
||||
params: { id: item.id }
|
||||
} as any);
|
||||
expect(response.status).toBe(409);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Cannot delete item that is currently being processed');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(409);
|
||||
expect(err.body.message).toBe('Cannot delete item that is currently being processed');
|
||||
}
|
||||
|
||||
// Verify item still exists
|
||||
expect(queueManager.get(item.id)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should validate ID format', async () => {
|
||||
const invalidIds = ['not-a-uuid', '12345', 'abc-def-ghi'];
|
||||
|
||||
for (const invalidId of invalidIds) {
|
||||
const request = new Request(`http://localhost/api/queue/${invalidId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await itemDELETE({
|
||||
request,
|
||||
params: { id: invalidId }
|
||||
} as any);
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe('Invalid queue item ID format');
|
||||
} catch (err: any) {
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.body.message).toBe('Invalid queue item ID format');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
356
src/tests/queue-manager.spec.ts
Normal file
356
src/tests/queue-manager.spec.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
250
src/tests/queue-processor.spec.ts
Normal file
250
src/tests/queue-processor.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Integration tests for QueueProcessor
|
||||
*
|
||||
* Tests the processor's ability to handle queue items through mocked dependencies.
|
||||
* The QueueProcessor auto-starts, so these tests verify actual processing behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { queueManager } from '$lib/server/queue/QueueManager';
|
||||
|
||||
// Mock queueConfig BEFORE importing QueueProcessor
|
||||
vi.mock('$lib/server/queue/config', () => ({
|
||||
queueConfig: {
|
||||
concurrency: 2,
|
||||
maxRetries: 3,
|
||||
tandoor: {
|
||||
enabled: true,
|
||||
token: 'test-token',
|
||||
serverUrl: 'http://localhost:8080'
|
||||
},
|
||||
push: {
|
||||
vapidPublicKey: 'test-public-key',
|
||||
vapidPrivateKey: 'test-private-key'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock external dependencies BEFORE importing QueueProcessor
|
||||
vi.mock('$lib/server/extraction', () => ({
|
||||
extractTextAndThumbnail: vi.fn().mockResolvedValue({
|
||||
bodyText: 'Default recipe text',
|
||||
thumbnail: null
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/parser', () => ({
|
||||
extractRecipe: vi.fn().mockResolvedValue({
|
||||
name: 'Default Recipe',
|
||||
ingredients: ['ingredient 1'],
|
||||
steps: ['step 1'],
|
||||
description: 'A default recipe'
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/tandoor', () => ({
|
||||
uploadRecipeWithIngredientsDTO: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
recipeId: 999
|
||||
}),
|
||||
uploadRecipeImage: vi.fn().mockResolvedValue({
|
||||
success: true
|
||||
})
|
||||
}));
|
||||
|
||||
import { extractTextAndThumbnail } from '$lib/server/extraction';
|
||||
import { extractRecipe } from '$lib/server/parser';
|
||||
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
||||
import * as configModule from '$lib/server/queue/config';
|
||||
|
||||
// Import processor AFTER mocks - it will auto-start (imported for side effects)
|
||||
import '$lib/server/queue/QueueProcessor';
|
||||
|
||||
describe('QueueProcessor Integration Tests', () => {
|
||||
beforeEach(async () => {
|
||||
// Clear queue
|
||||
queueManager.getAll().forEach(item => queueManager.remove(item.id));
|
||||
|
||||
// Reset mocks and their implementations
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Set default mock implementations
|
||||
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
|
||||
bodyText: 'Default recipe text',
|
||||
thumbnail: null
|
||||
});
|
||||
|
||||
vi.mocked(extractRecipe).mockResolvedValue({
|
||||
name: 'Default Recipe',
|
||||
ingredients: ['ingredient 1'],
|
||||
steps: ['step 1'],
|
||||
description: 'A default recipe'
|
||||
});
|
||||
|
||||
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
|
||||
success: true,
|
||||
recipeId: 999
|
||||
});
|
||||
|
||||
vi.mocked(uploadRecipeImage).mockResolvedValue({
|
||||
success: true
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Wait for any pending processing to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
it('should process item through all phases when Tandoor is configured', async () => {
|
||||
// Set up successful mocks
|
||||
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
|
||||
bodyText: 'Recipe instructions here',
|
||||
thumbnail: 'https://example.com/thumb.jpg'
|
||||
});
|
||||
|
||||
vi.mocked(extractRecipe).mockResolvedValue({
|
||||
name: 'Test Recipe',
|
||||
ingredients: ['flour', 'eggs'],
|
||||
steps: ['mix', 'bake'],
|
||||
description: 'test'
|
||||
});
|
||||
|
||||
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
|
||||
success: true,
|
||||
recipeId: 123
|
||||
});
|
||||
|
||||
// Enqueue (processor is already running from auto-start)
|
||||
// Note: Tandoor is enabled in the mocked config
|
||||
const item = queueManager.enqueue('https://instagram.com/p/test-tandoor');
|
||||
|
||||
// Wait for processing to complete - increased timeout
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const updated = queueManager.get(item.id);
|
||||
|
||||
// Verify success
|
||||
expect(updated?.status).toBe('success');
|
||||
expect(updated?.extractedText).toBe('Recipe instructions here');
|
||||
expect(updated?.recipe?.name).toBe('Test Recipe');
|
||||
expect(updated?.tandoorRecipeId).toBe(123);
|
||||
|
||||
// Verify all functions were called
|
||||
expect(extractTextAndThumbnail).toHaveBeenCalled();
|
||||
expect(extractRecipe).toHaveBeenCalled();
|
||||
expect(uploadRecipeWithIngredientsDTO).toHaveBeenCalled();
|
||||
}, 10000); // Increase timeout for processing
|
||||
|
||||
it('should skip Tandoor upload when not configured', async () => {
|
||||
// Temporarily disable Tandoor for this test
|
||||
const originalConfig = { ...configModule.queueConfig };
|
||||
vi.spyOn(configModule, 'queueConfig', 'get').mockReturnValue({
|
||||
...originalConfig,
|
||||
tandoor: {
|
||||
enabled: false,
|
||||
token: null,
|
||||
serverUrl: null
|
||||
}
|
||||
});
|
||||
|
||||
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
|
||||
bodyText: 'Recipe text',
|
||||
thumbnail: null
|
||||
});
|
||||
|
||||
vi.mocked(extractRecipe).mockResolvedValue({
|
||||
name: 'No Tandoor Recipe',
|
||||
ingredients: [],
|
||||
steps: [],
|
||||
description: ''
|
||||
});
|
||||
|
||||
const item = queueManager.enqueue('https://instagram.com/p/no-tandoor');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
const updated = queueManager.get(item.id);
|
||||
|
||||
// Should still succeed without Tandoor
|
||||
expect(updated?.status).toBe('success');
|
||||
expect(updated?.recipe?.name).toBe('No Tandoor Recipe');
|
||||
expect(uploadRecipeWithIngredientsDTO).not.toHaveBeenCalled();
|
||||
|
||||
// Restore mock
|
||||
vi.restoreAllMocks();
|
||||
}, 10000);
|
||||
|
||||
it('should handle extraction errors', async () => {
|
||||
vi.mocked(extractTextAndThumbnail).mockRejectedValue(
|
||||
new Error('Network timeout')
|
||||
);
|
||||
|
||||
const item = queueManager.enqueue('https://instagram.com/p/error');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
const updated = queueManager.get(item.id);
|
||||
|
||||
// Should mark as unhealthy (recoverable)
|
||||
expect(updated?.status).toBe('unhealthy');
|
||||
expect(updated?.error?.message).toContain('timeout');
|
||||
}, 10000);
|
||||
|
||||
it('should handle parsing failure', async () => {
|
||||
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
|
||||
bodyText: 'Not a recipe',
|
||||
thumbnail: null
|
||||
});
|
||||
|
||||
vi.mocked(extractRecipe).mockResolvedValue(null);
|
||||
|
||||
const item = queueManager.enqueue('https://instagram.com/p/not-recipe');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
const updated = queueManager.get(item.id);
|
||||
|
||||
// Should mark as error (non-recoverable - no recipe found)
|
||||
expect(updated?.status).toBe('error');
|
||||
expect(updated?.error?.message).toContain('recipe');
|
||||
}, 10000);
|
||||
|
||||
it('should process multiple items respecting concurrency', async () => {
|
||||
// Set up mocks with delay to observe concurrency
|
||||
vi.mocked(extractTextAndThumbnail).mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return { bodyText: 'text', thumbnail: null };
|
||||
});
|
||||
|
||||
vi.mocked(extractRecipe).mockResolvedValue({
|
||||
name: 'Concurrent Recipe',
|
||||
ingredients: [],
|
||||
steps: [],
|
||||
description: ''
|
||||
});
|
||||
|
||||
// Enqueue 3 items (Tandoor enabled by default in config mock)
|
||||
queueManager.enqueue('https://instagram.com/p/item1');
|
||||
queueManager.enqueue('https://instagram.com/p/item2');
|
||||
queueManager.enqueue('https://instagram.com/p/item3');
|
||||
|
||||
// Wait a bit for processor to start working
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
const items = queueManager.getAll();
|
||||
const inProgress = items.filter(i => i.status === 'in_progress');
|
||||
|
||||
// With concurrency=2, should have max 2 in progress at once
|
||||
expect(inProgress.length).toBeLessThanOrEqual(2);
|
||||
|
||||
// Wait for all to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const final = queueManager.getAll();
|
||||
const completed = final.filter(i => i.status === 'success');
|
||||
|
||||
// All 3 should eventually complete
|
||||
expect(completed.length).toBe(3);
|
||||
}, 15000);
|
||||
});
|
||||
141
src/tests/queue-sse.spec.ts
Normal file
141
src/tests/queue-sse.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Integration tests for Queue SSE Stream endpoint
|
||||
*
|
||||
* Tests the Server-Sent Events stream for real-time queue updates.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { queueManager } from '$lib/server/queue/QueueManager';
|
||||
import { GET as streamGET } from '../routes/api/queue/stream/+server.js';
|
||||
|
||||
describe('Queue SSE Stream Endpoint', () => {
|
||||
beforeEach(() => {
|
||||
// Clear queue between tests
|
||||
queueManager.getAll().forEach(item => queueManager.remove(item.id));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after tests
|
||||
queueManager.getAll().forEach(item => queueManager.remove(item.id));
|
||||
});
|
||||
|
||||
describe('GET /api/queue/stream', () => {
|
||||
it('should return SSE response with correct headers', async () => {
|
||||
const url = new URL('http://localhost/api/queue/stream');
|
||||
const request = new Request(url);
|
||||
|
||||
const response = await streamGET({
|
||||
url,
|
||||
request: {
|
||||
...request,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-cache');
|
||||
expect(response.headers.get('Connection')).toBe('keep-alive');
|
||||
});
|
||||
|
||||
it('should reject invalid status filter', async () => {
|
||||
const url = new URL('http://localhost/api/queue/stream?status=invalid');
|
||||
const request = new Request(url);
|
||||
|
||||
const response = await streamGET({
|
||||
url,
|
||||
request: {
|
||||
...request,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const text = await response.text();
|
||||
expect(text).toContain('Invalid status filter');
|
||||
});
|
||||
|
||||
it('should reject invalid item ID format', async () => {
|
||||
const url = new URL('http://localhost/api/queue/stream?id=invalid-id');
|
||||
const request = new Request(url);
|
||||
|
||||
const response = await streamGET({
|
||||
url,
|
||||
request: {
|
||||
...request,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const text = await response.text();
|
||||
expect(text).toBe('Invalid queue item ID format');
|
||||
});
|
||||
|
||||
it('should accept valid status filter', async () => {
|
||||
const url = new URL('http://localhost/api/queue/stream?status=pending');
|
||||
const request = new Request(url);
|
||||
|
||||
const response = await streamGET({
|
||||
url,
|
||||
request: {
|
||||
...request,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
|
||||
});
|
||||
|
||||
it('should accept valid item ID filter', async () => {
|
||||
// Add a test item first
|
||||
const item = queueManager.enqueue('https://instagram.com/p/TEST123');
|
||||
|
||||
const url = new URL(`http://localhost/api/queue/stream?id=${item.id}`);
|
||||
const request = new Request(url);
|
||||
|
||||
const response = await streamGET({
|
||||
url,
|
||||
request: {
|
||||
...request,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
|
||||
});
|
||||
|
||||
it('should handle stream initialization without errors', async () => {
|
||||
// Add some test items
|
||||
queueManager.enqueue('https://instagram.com/p/TEST1');
|
||||
queueManager.enqueue('https://instagram.com/p/TEST2');
|
||||
|
||||
const url = new URL('http://localhost/api/queue/stream');
|
||||
const abortController = new AbortController();
|
||||
const request = new Request(url, {
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
const response = await streamGET({
|
||||
url,
|
||||
request
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toBeInstanceOf(ReadableStream);
|
||||
|
||||
// Abort the request to clean up
|
||||
abortController.abort();
|
||||
});
|
||||
});
|
||||
|
||||
// Note: Full SSE stream testing would require more complex setup with
|
||||
// ReadableStream readers and async iteration, which is beyond the scope
|
||||
// of these basic endpoint validation tests. The above tests verify that:
|
||||
// 1. The endpoint responds correctly
|
||||
// 2. Headers are set properly for SSE
|
||||
// 3. Parameter validation works
|
||||
// 4. Stream initialization succeeds
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
|
||||
@@ -11,14 +11,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
* - Handles network errors gracefully
|
||||
*/
|
||||
|
||||
// Mock types matching the actual implementation
|
||||
type ProgressCallback = (event: {
|
||||
type: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
data?: any;
|
||||
}) => void;
|
||||
|
||||
describe('fetchImageAsBase64 URL Validation', () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
let mockProgressCallback: ReturnType<typeof vi.fn>;
|
||||
|
||||
Reference in New Issue
Block a user