fix: queue issue

This commit is contained in:
2026-07-31 07:33:12 +02:00
parent e7628ac44c
commit 10ef590242
10 changed files with 283 additions and 352 deletions

View File

@@ -105,7 +105,7 @@ describe('AnimeSettingsManager', () => {
getToken: vi.fn(() => 'fake-jwt-token'),
checkAuth: vi.fn().mockResolvedValue(true),
},
UiUtils: {
UI: {
showToast: vi.fn(),
},
};
@@ -226,7 +226,7 @@ describe('AnimeSettingsManager', () => {
it('handles 401 by calling showError', async () => {
mockFetchSequence([{ status: 401, body: { detail: 'unauthorized' } }]);
await manager.loadSeries('whatever');
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
expect.stringContaining('authenticated'),
'error'
);
@@ -297,7 +297,7 @@ describe('AnimeSettingsManager', () => {
body: { key: 'a', name: 'New Name' },
}]);
await manager.saveSettings({ applyToNfo: false });
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
expect.stringContaining('saved'),
'success'
);
@@ -309,7 +309,7 @@ describe('AnimeSettingsManager', () => {
body: { key: 'a', name: 'New Name', has_nfo: true },
}]);
await manager.saveSettings({ applyToNfo: true });
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
expect.stringContaining('regenerated'),
'success'
);
@@ -318,7 +318,7 @@ describe('AnimeSettingsManager', () => {
it('shows error toast on 422', async () => {
mockFetchSequence([{ status: 422, body: { detail: 'bad tmdb_id' } }]);
await manager.saveSettings({ applyToNfo: false });
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
expect.stringContaining('Validation'),
'error'
);
@@ -359,7 +359,7 @@ describe('AnimeSettingsManager', () => {
const [url, opts] = global.fetch.mock.calls[0];
expect(url).toBe('/api/anime/a/regenerate-nfo');
expect(opts.method).toBe('POST');
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
'NFO regenerated.',
'success'
);
@@ -368,7 +368,7 @@ describe('AnimeSettingsManager', () => {
it('shows error toast on 400 (no tmdb_id)', async () => {
mockFetchSequence([{ status: 400, body: { detail: 'no TMDB ID' } }]);
await manager.regenerateNfo();
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
expect.stringContaining('Cannot regenerate'),
'error'
);
@@ -467,18 +467,18 @@ describe('AnimeSettingsManager', () => {
// -------------------------------------------------------------------
describe('showSaveSuccess()', () => {
it('calls AniWorld.UiUtils.showToast with success type', () => {
it('calls AniWorld.UI.showToast with success type', () => {
manager.showSaveSuccess('Saved!');
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
'Saved!', 'success'
);
});
});
describe('showError()', () => {
it('calls AniWorld.UiUtils.showToast with error type', () => {
it('calls AniWorld.UI.showToast with error type', () => {
manager.showError('Boom');
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
'Boom', 'error'
);
});

View File

@@ -5,6 +5,26 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Load the real queue-api.js module
function loadQueueAPI() {
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(
path.resolve(__dirname, '../../../src/server/web/static/js/queue/queue-api.js'),
'utf8'
);
// eslint-disable-next-line no-eval
(0, eval)(src);
return global.AniWorld.QueueAPI;
}
// Stub the minimal dependencies queue-api.js needs that aren't in setupMockAniWorld
function stubQueueAPI() {
// queue-api.js calls AniWorld.Constants.API.* — those are already in setupMockAniWorld
// AniWorld.ApiClient is already a vi.fn() stub in setupMockAniWorld
// Nothing extra needed — the ApiClient stubs are already correct
}
// Mock DOM setup
function setupDOM() {
document.body.innerHTML = `
@@ -93,24 +113,36 @@ function setupMockAniWorld() {
ProgressHandler: {
processPendingProgressUpdates: vi.fn(),
updateProgress: vi.fn()
},
QueueAPI: {
loadQueueData: vi.fn(),
startQueue: vi.fn(),
stopQueue: vi.fn(),
removeFromQueue: vi.fn(),
retryDownloads: vi.fn(),
clearCompleted: vi.fn(),
clearFailed: vi.fn(),
clearPending: vi.fn()
}
// QueueAPI intentionally omitted — tests that need it call loadQueueAPI()
// to get the real module; inline handlers in button tests need the mock to
// delegate, so we patch it after setupMockAniWorld in those describe blocks.
};
}
// Patch setupMockAniWorld's QueueAPI stub to delegate to the real module.
// Called inside each beforeEach that has inline handlers referencing QueueAPI.
function patchQueueAPIDelegate() {
const real = loadQueueAPI();
global.AniWorld.QueueAPI = {
loadQueueData: real.loadQueueData,
startQueue: real.startQueue,
stopQueue: real.stopQueue,
removeFromQueue: real.removeFromQueue,
retryDownloads: real.retryDownloads,
clearCompleted: real.clearCompleted,
clearFailed: real.clearFailed,
clearPending: real.clearPending,
};
}
describe('Queue API - Data Loading', () => {
let QueueAPI;
beforeEach(() => {
setupDOM();
setupMockAniWorld();
QueueAPI = loadQueueAPI();
});
afterEach(() => {
@@ -141,7 +173,7 @@ describe('Queue API - Data Loading', () => {
};
global.AniWorld.ApiClient.get.mockResolvedValue(mockResponse);
const data = await global.AniWorld.QueueAPI.loadQueueData();
const data = await QueueAPI.loadQueueData();
expect(global.AniWorld.ApiClient.get).toHaveBeenCalledWith('/api/queue/status');
expect(data).toHaveProperty('statistics');
@@ -151,7 +183,7 @@ describe('Queue API - Data Loading', () => {
it('should handle API error gracefully', async () => {
global.AniWorld.ApiClient.get.mockRejectedValue(new Error('Network error'));
const data = await global.AniWorld.QueueAPI.loadQueueData();
const data = await QueueAPI.loadQueueData();
expect(data).toBeNull();
});
@@ -176,7 +208,7 @@ describe('Queue API - Data Loading', () => {
};
global.AniWorld.ApiClient.get.mockResolvedValue(mockResponse);
const data = await global.AniWorld.QueueAPI.loadQueueData();
const data = await QueueAPI.loadQueueData();
expect(data.is_running).toBe(true);
expect(data.pending_items).toHaveLength(1);
@@ -185,9 +217,12 @@ describe('Queue API - Data Loading', () => {
});
describe('Queue API - Queue Control', () => {
let QueueAPI;
beforeEach(() => {
setupDOM();
setupMockAniWorld();
QueueAPI = loadQueueAPI();
});
afterEach(() => {
@@ -200,7 +235,7 @@ describe('Queue API - Queue Control', () => {
};
global.AniWorld.ApiClient.post.mockResolvedValue(mockResponse);
const result = await global.AniWorld.QueueAPI.startQueue();
const result = await QueueAPI.startQueue();
expect(global.AniWorld.ApiClient.post).toHaveBeenCalledWith('/api/queue/start', {});
expect(result.message).toBe('Queue started');
@@ -212,29 +247,32 @@ describe('Queue API - Queue Control', () => {
};
global.AniWorld.ApiClient.post.mockResolvedValue(mockResponse);
const result = await global.AniWorld.QueueAPI.stopQueue();
const result = await QueueAPI.stopQueue();
expect(global.AniWorld.ApiClient.post).toHaveBeenCalledWith('/api/queue/stop', {});
expect(result.message).toBe('Queue stopped');
});
it('should handle start queue error', async () => {
global.AniWorld.ApiClient.post.mockRejectedValue(new Error('Already running'));
await expect(global.AniWorld.QueueAPI.startQueue()).rejects.toThrow('Already running');
global.AniWorld.ApiClient.post.mockRejectedValue(new Error('Network error'));
await expect(QueueAPI.startQueue()).rejects.toThrow('Network error');
});
it('should handle stop queue error', async () => {
global.AniWorld.ApiClient.post.mockRejectedValue(new Error('Not running'));
await expect(global.AniWorld.QueueAPI.stopQueue()).rejects.toThrow('Not running');
global.AniWorld.ApiClient.post.mockRejectedValue(new Error('Network error'));
await expect(QueueAPI.stopQueue()).rejects.toThrow('Network error');
});
});
describe('Queue API - Item Management', () => {
let QueueAPI;
beforeEach(() => {
setupDOM();
setupMockAniWorld();
QueueAPI = loadQueueAPI();
});
afterEach(() => {
@@ -247,7 +285,7 @@ describe('Queue API - Item Management', () => {
};
global.AniWorld.ApiClient.delete.mockResolvedValue(mockResponse);
const result = await global.AniWorld.QueueAPI.removeFromQueue('item-123');
const result = await QueueAPI.removeFromQueue('item-123');
expect(global.AniWorld.ApiClient.delete).toHaveBeenCalledWith('/api/queue/remove/item-123');
expect(result).toBe(true);
@@ -260,7 +298,7 @@ describe('Queue API - Item Management', () => {
global.AniWorld.ApiClient.post.mockResolvedValue(mockResponse);
const itemIds = ['item-1', 'item-2'];
const result = await global.AniWorld.QueueAPI.retryDownloads(itemIds);
const result = await QueueAPI.retryDownloads(itemIds);
expect(global.AniWorld.ApiClient.post).toHaveBeenCalledWith('/api/queue/retry', { item_ids: itemIds });
expect(result.retried).toBe(2);
@@ -272,7 +310,7 @@ describe('Queue API - Item Management', () => {
};
global.AniWorld.ApiClient.delete.mockResolvedValue(mockResponse);
const result = await global.AniWorld.QueueAPI.clearCompleted();
const result = await QueueAPI.clearCompleted();
expect(global.AniWorld.ApiClient.delete).toHaveBeenCalledWith('/api/queue/completed');
expect(result.cleared).toBe(5);
@@ -284,7 +322,7 @@ describe('Queue API - Item Management', () => {
};
global.AniWorld.ApiClient.delete.mockResolvedValue(mockResponse);
const result = await global.AniWorld.QueueAPI.clearFailed();
const result = await QueueAPI.clearFailed();
expect(global.AniWorld.ApiClient.delete).toHaveBeenCalledWith('/api/queue/failed');
expect(result.cleared).toBe(3);
@@ -296,7 +334,7 @@ describe('Queue API - Item Management', () => {
};
global.AniWorld.ApiClient.delete.mockResolvedValue(mockResponse);
const result = await global.AniWorld.QueueAPI.clearPending();
const result = await QueueAPI.clearPending();
expect(global.AniWorld.ApiClient.delete).toHaveBeenCalledWith('/api/queue/pending');
expect(result.cleared).toBe(2);
@@ -339,6 +377,15 @@ describe('Queue Renderer - Statistics Display', () => {
});
it('should handle zero statistics', () => {
// Rebuild DOM from scratch
document.body.innerHTML = `
<span id="pending-count"></span>
<span id="active-count"></span>
<span id="completed-count"></span>
<span id="failed-count"></span>
<span id="total-count"></span>
`;
const data = {
statistics: {
pending: 0,
@@ -348,20 +395,21 @@ describe('Queue Renderer - Statistics Display', () => {
total: 0
}
};
document.getElementById('pending-count').textContent = data.statistics.pending;
document.getElementById('active-count').textContent = data.statistics.active;
document.getElementById('completed-count').textContent = data.statistics.completed;
document.getElementById('failed-count').textContent = data.statistics.failed;
document.getElementById('total-count').textContent = data.statistics.total;
// Use innerHTML to set values directly (avoids textContent coercion issues in JSDOM)
document.getElementById('pending-count').innerHTML = data.statistics.pending;
document.getElementById('active-count').innerHTML = data.statistics.active;
document.getElementById('completed-count').innerHTML = data.statistics.completed;
document.getElementById('failed-count').innerHTML = data.statistics.failed;
document.getElementById('total-count').innerHTML = data.statistics.total;
expect(document.getElementById('pending-count').textContent).toBe('0');
expect(document.getElementById('active-count').textContent).toBe('0');
expect(document.getElementById('completed-count').textContent).toBe('0');
expect(document.getElementById('failed-count').textContent).toBe('0');
expect(document.getElementById('total-count').textContent).toBe('0');
});
it('should update statistics when queue changes', () => {
// Initial state
document.getElementById('pending-count').textContent = '5';
@@ -540,6 +588,7 @@ describe('Queue Button Handlers', () => {
beforeEach(() => {
setupDOM();
setupMockAniWorld();
patchQueueAPIDelegate();
});
afterEach(() => {
@@ -810,6 +859,12 @@ describe('Queue Edge Cases', () => {
});
it('should handle empty queue gracefully', () => {
// Rebuild DOM from scratch to guarantee clean state
document.body.innerHTML = `
<span id="pending-count"></span>
<div id="pending-queue"></div>
`;
const data = {
statistics: {
pending: 0,
@@ -823,10 +878,11 @@ describe('Queue Edge Cases', () => {
completed_items: [],
failed_items: []
};
document.getElementById('pending-count').textContent = data.statistics.pending;
// Use innerHTML to set values (avoids textContent coercion issues in JSDOM)
document.getElementById('pending-count').innerHTML = data.statistics.pending;
document.getElementById('pending-queue').innerHTML = '';
expect(document.getElementById('pending-count').textContent).toBe('0');
expect(document.getElementById('pending-queue').children.length).toBe(0);
});

View File

@@ -99,6 +99,27 @@ MockWebSocket.CLOSED = 3;
// For testing, we'll load the actual file
let WebSocketClient;
// Load the WebSocket client source (used by multiple describe blocks)
function loadWebSocketClientSource() {
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(
path.resolve(__dirname, '../../../src/server/web/static/js/shared/websocket-client.js'),
'utf8'
);
// eslint-disable-next-line no-eval
(0, eval)(src);
// Provide a Socket.IO-like io() factory for tests that use it
if (typeof globalThis.io === 'undefined') {
globalThis.io = function (url) {
const client = new globalThis.WebSocketClient(url);
client.connect();
return client;
};
}
return globalThis.WebSocketClient;
}
describe('WebSocket Client - Initialization', () => {
beforeEach(() => {
// Mock global WebSocket
@@ -106,174 +127,9 @@ describe('WebSocket Client - Initialization', () => {
// Clear any timers
vi.useFakeTimers();
// Load WebSocketClient class by evaluating the source
// In a real setup, this would be imported
const sourceCode = `
class WebSocketClient {
constructor(url, options = {}) {
this.url = url;
this.ws = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
this.reconnectDelay = options.reconnectDelay || 1000;
this.autoReconnect = options.autoReconnect !== false;
this.eventHandlers = new Map();
this.messageQueue = [];
this.rooms = new Set();
}
getWebSocketUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
return \`\${protocol}//\${host}\${this.url}\`;
}
connect() {
try {
const wsUrl = this.getWebSocketUrl();
this.ws = new WebSocket(wsUrl);
this.ws.onopen = (event) => {
this.isConnected = true;
this.reconnectAttempts = 0;
this.emit('connect');
this.rejoinRooms();
this.processMessageQueue();
};
this.ws.onmessage = (event) => {
this.handleMessage(event);
};
this.ws.onerror = (event) => {
console.error('WebSocket error:', event);
this.emit('error', event.error || new Error('WebSocket error'));
};
this.ws.onclose = (event) => {
this.isConnected = false;
this.emit('disconnect', event.reason);
if (this.autoReconnect && !event.wasClean &&
this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * this.reconnectAttempts;
console.log(\`Reconnecting in \${delay}ms (attempt \${this.reconnectAttempts}/\${this.maxReconnectAttempts})...\`);
setTimeout(() => this.connect(), delay);
} else if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.emit('reconnect_failed');
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
this.emit('error', error);
}
}
disconnect() {
if (this.ws) {
this.autoReconnect = false;
this.ws.close(1000, 'Client disconnect');
}
}
handleMessage(event) {
try {
const message = JSON.parse(event.data);
const { type, ...data } = message;
if (type) {
this.emit(type, data);
}
} catch (error) {
console.error('Failed to parse message:', error);
this.emit('error', error);
}
}
on(event, handler) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, []);
}
this.eventHandlers.get(event).push(handler);
}
off(event, handler) {
if (this.eventHandlers.has(event)) {
const handlers = this.eventHandlers.get(event);
const index = handlers.indexOf(handler);
if (index !== -1) {
handlers.splice(index, 1);
}
}
}
emit(event, data) {
if (this.eventHandlers.has(event)) {
this.eventHandlers.get(event).forEach(handler => {
try {
handler(data);
} catch (error) {
console.error(\`Error in event handler for '\${event}':\`, error);
}
});
}
}
send(action, data) {
const message = JSON.stringify({ action, ...data });
if (this.connected()) {
this.ws.send(message);
} else {
this.messageQueue.push(message);
}
}
join(room) {
this.rooms.add(room);
if (this.connected()) {
this.send('join', { room });
}
}
leave(room) {
this.rooms.delete(room);
if (this.connected()) {
this.send('leave', { room });
}
}
rejoinRooms() {
this.rooms.forEach(room => {
this.send('join', { room });
});
}
processMessageQueue() {
while (this.messageQueue.length > 0 && this.connected()) {
const message = this.messageQueue.shift();
this.ws.send(message);
}
}
connected() {
return this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN;
}
}
function io(url) {
const client = new WebSocketClient(url);
client.connect();
return client;
}
globalThis.WebSocketClient = WebSocketClient;
globalThis.io = io;
`;
eval(sourceCode);
WebSocketClient = globalThis.WebSocketClient;
// Load WebSocketClient class from the real source
WebSocketClient = loadWebSocketClientSource();
});
afterEach(() => {
@@ -340,9 +196,8 @@ describe('WebSocket Client - Connection', () => {
}
};
const sourceCode = `${/* Same source as above */}`;
eval(sourceCode);
WebSocketClient = globalThis.WebSocketClient;
// Load WebSocketClient class from the real source
WebSocketClient = loadWebSocketClientSource();
});
afterEach(() => {