From e81ee40a1d2c8ee749973036fc0cfd1ee8aea6b2 Mon Sep 17 00:00:00 2001 From: Xoconoch Date: Sat, 7 Jun 2025 14:56:13 -0600 Subject: [PATCH] test suite --- .dockerignore | 1 + routes/album.py | 12 +-- routes/playlist.py | 12 +-- routes/prgs.py | 14 ++- routes/track.py | 12 +-- src/js/queue.ts | 202 ++++++++++++++++++++-------------------- tests/README.md | 44 +++++++++ tests/__init__.py | 1 + tests/conftest.py | 149 +++++++++++++++++++++++++++++ tests/test_config.py | 94 +++++++++++++++++++ tests/test_downloads.py | 128 +++++++++++++++++++++++++ tests/test_history.py | 61 ++++++++++++ tests/test_prgs.py | 93 ++++++++++++++++++ tests/test_search.py | 35 +++++++ tests/test_watch.py | 117 +++++++++++++++++++++++ 15 files changed, 846 insertions(+), 129 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_downloads.py create mode 100644 tests/test_history.py create mode 100644 tests/test_prgs.py create mode 100644 tests/test_search.py create mode 100644 tests/test_watch.py diff --git a/.dockerignore b/.dockerignore index 617bfc5..790e94d 100755 --- a/.dockerignore +++ b/.dockerignore @@ -24,3 +24,4 @@ logs/ .env .venv data +tests/ \ No newline at end of file diff --git a/routes/album.py b/routes/album.py index 98f6d6d..6b24c4a 100755 --- a/routes/album.py +++ b/routes/album.py @@ -111,25 +111,25 @@ def handle_download(album_id): ) return Response( - json.dumps({"prg_file": task_id}), status=202, mimetype="application/json" + json.dumps({"task_id": task_id}), status=202, mimetype="application/json" ) @album_bp.route("/download/cancel", methods=["GET"]) def cancel_download(): """ - Cancel a running download process by its prg file name. + Cancel a running download process by its task id. """ - prg_file = request.args.get("prg_file") - if not prg_file: + task_id = request.args.get("task_id") + if not task_id: return Response( - json.dumps({"error": "Missing process id (prg_file) parameter"}), + json.dumps({"error": "Missing process id (task_id) parameter"}), status=400, mimetype="application/json", ) # Use the queue manager's cancellation method. - result = download_queue_manager.cancel_task(prg_file) + result = download_queue_manager.cancel_task(task_id) status_code = 200 if result.get("status") == "cancelled" else 404 return Response(json.dumps(result), status=status_code, mimetype="application/json") diff --git a/routes/playlist.py b/routes/playlist.py index 268b772..a17a98f 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -133,7 +133,7 @@ def handle_download(playlist_id): ) return Response( - json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id + json.dumps({"task_id": task_id}), status=202, mimetype="application/json", ) @@ -142,18 +142,18 @@ def handle_download(playlist_id): @playlist_bp.route("/download/cancel", methods=["GET"]) def cancel_download(): """ - Cancel a running playlist download process by its prg file name. + Cancel a running playlist download process by its task id. """ - prg_file = request.args.get("prg_file") - if not prg_file: + task_id = request.args.get("task_id") + if not task_id: return Response( - json.dumps({"error": "Missing process id (prg_file) parameter"}), + json.dumps({"error": "Missing task id (task_id) parameter"}), status=400, mimetype="application/json", ) # Use the queue manager's cancellation method. - result = download_queue_manager.cancel_task(prg_file) + result = download_queue_manager.cancel_task(task_id) status_code = 200 if result.get("status") == "cancelled" else 404 return Response(json.dumps(result), status=status_code, mimetype="application/json") diff --git a/routes/prgs.py b/routes/prgs.py index 5795ee8..c6d0d92 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -21,16 +21,15 @@ prgs_bp = Blueprint("prgs", __name__, url_prefix="/api/prgs") @prgs_bp.route("/", methods=["GET"]) -def get_prg_file(task_id): +def get_task_details(task_id): """ Return a JSON object with the resource type, its name (title), the last progress update, and, if available, the original request parameters. - This function works with both the old PRG file system (for backward compatibility) - and the new task ID based system. + This function works with the new task ID based system. Args: - task_id: Either a task UUID from Celery or a PRG filename from the old system + task_id: A task UUID from Celery """ # Only support new task IDs task_info = get_task_info(task_id) @@ -88,13 +87,12 @@ def get_prg_file(task_id): @prgs_bp.route("/delete/", methods=["DELETE"]) -def delete_prg_file(task_id): +def delete_task(task_id): """ Delete a task's information and history. - Works with both the old PRG file system and the new task ID based system. Args: - task_id: Either a task UUID from Celery or a PRG filename from the old system + task_id: A task UUID from Celery """ # Only support new task IDs task_info = get_task_info(task_id) @@ -107,7 +105,7 @@ def delete_prg_file(task_id): @prgs_bp.route("/list", methods=["GET"]) -def list_prg_files(): +def list_tasks(): """ Retrieve a list of all tasks in the system. Returns a detailed list of task objects including status and metadata. diff --git a/routes/track.py b/routes/track.py index 01406c9..3f86828 100755 --- a/routes/track.py +++ b/routes/track.py @@ -127,7 +127,7 @@ def handle_download(track_id): ) return Response( - json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id + json.dumps({"task_id": task_id}), status=202, mimetype="application/json", ) @@ -136,18 +136,18 @@ def handle_download(track_id): @track_bp.route("/download/cancel", methods=["GET"]) def cancel_download(): """ - Cancel a running track download process by its process id (prg file name). + Cancel a running track download process by its task id. """ - prg_file = request.args.get("prg_file") - if not prg_file: + task_id = request.args.get("task_id") + if not task_id: return Response( - json.dumps({"error": "Missing process id (prg_file) parameter"}), + json.dumps({"error": "Missing task id (task_id) parameter"}), status=400, mimetype="application/json", ) # Use the queue manager's cancellation method. - result = download_queue_manager.cancel_task(prg_file) + result = download_queue_manager.cancel_task(task_id) status_code = 200 if result.get("status") == "cancelled" else 404 return Response(json.dumps(result), status=status_code, mimetype="application/json") diff --git a/src/js/queue.ts b/src/js/queue.ts index eeab46f..9db4fdf 100644 --- a/src/js/queue.ts +++ b/src/js/queue.ts @@ -71,7 +71,7 @@ interface StatusData { retry_count?: number; max_retries?: number; // from config potentially seconds_left?: number; - prg_file?: string; + task_id?: string; url?: string; reason?: string; // for skipped parent?: ParentInfo; @@ -100,7 +100,7 @@ interface StatusData { interface QueueEntry { item: QueueItem; type: string; - prgFile: string; + taskId: string; requestUrl: string | null; element: HTMLElement; lastStatus: StatusData; @@ -196,13 +196,13 @@ export class DownloadQueue { // const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); // this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10; - // Load the cached status info (object keyed by prgFile) - This is also redundant + // Load the cached status info (object keyed by taskId) - This is also redundant // this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}"); // Wait for initDOM to complete before setting up event listeners and loading existing PRG files. this.initDOM().then(() => { this.initEventListeners(); - this.loadExistingPrgFiles(); + this.loadExistingTasks(); // Start periodic sync setInterval(() => this.periodicSyncWithServer(), 10000); // Sync every 10 seconds }); @@ -278,8 +278,8 @@ export class DownloadQueue { cancelAllBtn.addEventListener('click', () => { for (const queueId in this.queueEntries) { const entry = this.queueEntries[queueId]; - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (entry && !entry.hasEnded && entry.prgFile) { + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (entry && !entry.hasEnded && entry.taskId) { // Mark as cancelling visually if (entry.element) { entry.element.classList.add('cancelling'); @@ -289,7 +289,7 @@ export class DownloadQueue { } // Cancel each active download - fetch(`/api/${entry.type}/download/cancel?prg_file=${entry.prgFile}`) + fetch(`/api/${entry.type}/download/cancel?task_id=${entry.taskId}`) .then(response => response.json()) .then(data => { // API returns status 'cancelled' when cancellation succeeds @@ -388,9 +388,9 @@ export class DownloadQueue { /** * Adds a new download entry. */ - addDownload(item: QueueItem, type: string, prgFile: string, requestUrl: string | null = null, startMonitoring: boolean = false): string { + addDownload(item: QueueItem, type: string, taskId: string, requestUrl: string | null = null, startMonitoring: boolean = false): string { const queueId = this.generateQueueId(); - const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl); + const entry = this.createQueueEntry(item, type, taskId, queueId, requestUrl); this.queueEntries[queueId] = entry; // Re-render and update which entries are processed. this.updateQueueOrder(); @@ -417,17 +417,17 @@ export class DownloadQueue { // Show a preparing message for new entries if (entry.isNew) { - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; if (logElement) { logElement.textContent = "Initializing download..."; } } - console.log(`Starting monitoring for ${entry.type} with PRG file: ${entry.prgFile}`); + console.log(`Starting monitoring for ${entry.type} with task ID: ${entry.taskId}`); // For backward compatibility, first try to get initial status from the REST API try { - const response = await fetch(`/api/prgs/${entry.prgFile}`); + const response = await fetch(`/api/prgs/${entry.taskId}`); if (response.ok) { const data: StatusData = await response.json(); // Add type to data @@ -464,7 +464,7 @@ export class DownloadQueue { entry.status = data.last_line.status || 'unknown'; // Ensure status is not undefined // Update status message without recreating the element - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; if (logElement) { const statusMessage = this.getStatusMessage(data.last_line); logElement.textContent = statusMessage; @@ -474,7 +474,7 @@ export class DownloadQueue { this.applyStatusClasses(entry, data.last_line); // Save updated status to cache, ensuring we preserve parent data - this.queueCache[entry.prgFile] = { + this.queueCache[entry.taskId] = { ...data.last_line, // Ensure parent data is preserved parent: data.last_line.parent || entry.lastStatus?.parent @@ -540,11 +540,11 @@ export class DownloadQueue { /** * Creates a new queue entry. It checks localStorage for any cached info. */ - createQueueEntry(item: QueueItem, type: string, prgFile: string, queueId: string, requestUrl: string | null): QueueEntry { + createQueueEntry(item: QueueItem, type: string, taskId: string, queueId: string, requestUrl: string | null): QueueEntry { console.log(`Creating queue entry with initial type: ${type}`); // Get cached data if it exists - const cachedData: StatusData | undefined = this.queueCache[prgFile]; // Add type + const cachedData: StatusData | undefined = this.queueCache[taskId]; // Add type // If we have cached data, use it to determine the true type and item properties if (cachedData) { @@ -588,9 +588,9 @@ export class DownloadQueue { const entry: QueueEntry = { // Add type to entry item, type, - prgFile, + taskId, requestUrl, // for potential retry - element: this.createQueueItem(item, type, prgFile, queueId), + element: this.createQueueItem(item, type, taskId, queueId), lastStatus: { // Initialize with basic item metadata for immediate display type, @@ -615,7 +615,7 @@ export class DownloadQueue { realTimeStallDetector: { count: 0, lastStatusJson: '' } // For detecting stalled real_time downloads }; - // If cached info exists for this PRG file, use it. + // If cached info exists for this task, use it. if (cachedData) { entry.lastStatus = cachedData; const logEl = entry.element.querySelector('.log') as HTMLElement | null; @@ -640,7 +640,7 @@ export class DownloadQueue { /** * Returns an HTML element for the queue entry with modern UI styling. */ -createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string): HTMLElement { +createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): HTMLElement { // Track whether this is a multi-track item (album or playlist) const isMultiTrack = type === 'album' || type === 'playlist'; const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...'; @@ -664,26 +664,26 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) ${displayArtist ? `
${displayArtist}
` : ''}
${displayType}
-
-
${defaultMessage}
+
${defaultMessage}
- +
-
-
+
-
+
`; @@ -693,10 +693,10 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
Overall Progress - 0/0 + 0/0
-
`; @@ -745,7 +745,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) case 'error': entry.element.classList.add('error'); // Hide error-details to prevent duplicate error display - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; if (errorDetailsContainer) { errorDetailsContainer.style.display = 'none'; } @@ -755,7 +755,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) entry.element.classList.add('complete'); // Hide error details if present if (entry.element) { - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; if (errorDetailsContainer) { errorDetailsContainer.style.display = 'none'; } @@ -765,7 +765,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) entry.element.classList.add('cancelled'); // Hide error details if present if (entry.element) { - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; if (errorDetailsContainer) { errorDetailsContainer.style.display = 'none'; } @@ -778,8 +778,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) const btn = (e.target as HTMLElement).closest('button') as HTMLButtonElement | null; // Add types and null check if (!btn) return; // Guard clause btn.style.display = 'none'; - const { prg, type, queueid } = btn.dataset; - if (!prg || !type || !queueid) return; // Guard against undefined dataset properties + const { taskid, type, queueid } = btn.dataset; + if (!taskid || !type || !queueid) return; // Guard against undefined dataset properties try { // Get the queue item element @@ -790,13 +790,13 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) } // Show cancellation in progress - const logElement = document.getElementById(`log-${queueid}-${prg}`) as HTMLElement | null; + const logElement = document.getElementById(`log-${queueid}-${taskid}`) as HTMLElement | null; if (logElement) { logElement.textContent = "Cancelling..."; } // First cancel the download - const response = await fetch(`/api/${type}/download/cancel?prg_file=${prg}`); + const response = await fetch(`/api/${type}/download/cancel?task_id=${taskid}`); const data = await response.json(); // API returns status 'cancelled' when cancellation succeeds if (data.status === "cancelled" || data.status === "cancel") { @@ -813,7 +813,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Mark as cancelled in the cache to prevent re-loading on page refresh entry.status = "cancelled"; - this.queueCache[prg] = { status: "cancelled" }; + this.queueCache[taskid] = { status: "cancelled" }; localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); // Immediately remove the item from the UI @@ -924,7 +924,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // This is important for items that become visible after "Show More" or other UI changes Object.values(this.queueEntries).forEach(entry => { if (this.isEntryVisible(entry.uniqueId) && !entry.hasEnded && !this.pollingIntervals[entry.uniqueId]) { - console.log(`updateQueueOrder: Ensuring polling for visible/active entry ${entry.uniqueId} (${entry.prgFile})`); + console.log(`updateQueueOrder: Ensuring polling for visible/active entry ${entry.uniqueId} (${entry.taskId})`); this.setupPollingInterval(entry.uniqueId); } }); @@ -995,8 +995,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) delete this.queueEntries[queueId]; // Remove the cached info - if (this.queueCache[entry.prgFile]) { - delete this.queueCache[entry.prgFile]; + if (this.queueCache[entry.taskId]) { + delete this.queueCache[entry.taskId]; localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); } @@ -1025,13 +1025,13 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Find the queue item this status belongs to let queueItem: QueueEntry | null = null; - const prgFile = data.prg_file || Object.keys(this.queueCache).find(key => + const taskId = data.task_id || Object.keys(this.queueCache).find(key => this.queueCache[key].status === data.status && this.queueCache[key].type === data.type ); - if (prgFile) { + if (taskId) { const queueId = Object.keys(this.queueEntries).find(id => - this.queueEntries[id].prgFile === prgFile + this.queueEntries[id].taskId === taskId ); if (queueId) { queueItem = this.queueEntries[queueId]; @@ -1408,17 +1408,17 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) const retryData: StatusData = await retryResponse.json(); // Add type - if (retryData.prg_file) { - const newPrgFile = retryData.prg_file; + if (retryData.task_id) { + const newTaskId = retryData.task_id; - // Clean up the old entry from UI, memory, cache, and server (PRG file) + // Clean up the old entry from UI, memory, cache, and server (task file) // logElement and retryBtn are part of the old entry's DOM structure and will be removed. await this.cleanupEntry(queueId); // Add the new download entry. This will create a new element, start monitoring, etc. - this.addDownload(originalItem, apiTypeForNewEntry, newPrgFile, requestUrlForNewEntry, true); + this.addDownload(originalItem, apiTypeForNewEntry, newTaskId, requestUrlForNewEntry, true); - // The old setTimeout block for deleting oldPrgFile is no longer needed as cleanupEntry handles it. + // The old setTimeout block for deleting old task file is no longer needed as cleanupEntry handles it. } else { if (errorMessageDiv) errorMessageDiv.textContent = 'Retry failed: invalid response from server.'; const currentEntry = this.queueEntries[queueId]; // Check if old entry still exists @@ -1574,8 +1574,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Make queue visible this.toggleVisibility(true); - // Just load existing PRG files as a fallback - await this.loadExistingPrgFiles(); + // Just load existing task files as a fallback + await this.loadExistingTasks(); // Force start monitoring for all loaded entries for (const queueId in this.queueEntries) { @@ -1590,12 +1590,12 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) } // Handle single-file downloads (tracks, albums, playlists) - if ('prg_file' in data && data.prg_file) { // Type guard - console.log(`Adding ${type} PRG file: ${data.prg_file}`); + if ('task_id' in data && data.task_id) { // Type guard + console.log(`Adding ${type} task with ID: ${data.task_id}`); // Store the initial metadata in the cache so it's available // even before the first status update - this.queueCache[data.prg_file] = { + this.queueCache[data.task_id] = { type, status: 'initializing', name: item.name || 'Unknown', @@ -1606,7 +1606,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) }; // Use direct monitoring for all downloads for consistency - const queueId = this.addDownload(item, type, data.prg_file, apiUrl, true); + const queueId = this.addDownload(item, type, data.task_id, apiUrl, true); // Make queue visible to show progress if not already visible if (this.config && !this.config.downloadQueueVisible) { // Add null check for config @@ -1624,9 +1624,9 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) } /** - * Loads existing PRG files from the /api/prgs/list endpoint and adds them as queue entries. + * Loads existing task files from the /api/prgs/list endpoint and adds them as queue entries. */ - async loadExistingPrgFiles() { + async loadExistingTasks() { try { // Clear existing queue entries first to avoid duplicates when refreshing for (const queueId in this.queueEntries) { @@ -1646,23 +1646,23 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error']; for (const taskData of existingTasks) { - const prgFile = taskData.task_id; // Use task_id as prgFile identifier + const taskId = taskData.task_id; // Use task_id as taskId identifier const lastStatus = taskData.last_status_obj; const originalRequest = taskData.original_request || {}; // Skip adding to UI if the task is already in a terminal state if (lastStatus && terminalStates.includes(lastStatus.status)) { - console.log(`Skipping UI addition for terminal task ${prgFile}, status: ${lastStatus.status}`); + console.log(`Skipping UI addition for terminal task ${taskId}, status: ${lastStatus.status}`); // Also ensure it's cleaned from local cache if it was there - if (this.queueCache[prgFile]) { - delete this.queueCache[prgFile]; + if (this.queueCache[taskId]) { + delete this.queueCache[taskId]; } continue; } let itemType = taskData.type || originalRequest.type || 'unknown'; let dummyItem: QueueItem = { - name: taskData.name || originalRequest.name || prgFile, + name: taskData.name || originalRequest.name || taskId, artist: taskData.artist || originalRequest.artist || '', type: itemType, url: originalRequest.url || lastStatus?.url || '', @@ -1680,29 +1680,25 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) dummyItem = { name: parent.title || 'Unknown Album', artist: parent.artist || 'Unknown Artist', - type: 'album', - url: parent.url || '', + type: 'album', url: parent.url || '', total_tracks: parent.total_tracks || lastStatus.total_tracks, - parent: parent - }; + parent: parent }; } else if (parent.type === 'playlist') { itemType = 'playlist'; dummyItem = { name: parent.name || 'Unknown Playlist', owner: parent.owner || 'Unknown Creator', - type: 'playlist', - url: parent.url || '', + type: 'playlist', url: parent.url || '', total_tracks: parent.total_tracks || lastStatus.total_tracks, - parent: parent - }; + parent: parent }; } } let retryCount = 0; if (lastStatus && lastStatus.retry_count) { retryCount = lastStatus.retry_count; - } else if (prgFile.includes('_retry')) { - const retryMatch = prgFile.match(/_retry(\d+)/); + } else if (taskId.includes('_retry')) { + const retryMatch = taskId.match(/_retry(\d+)/); if (retryMatch && retryMatch[1]) { retryCount = parseInt(retryMatch[1], 10); } @@ -1711,7 +1707,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) const requestUrl = originalRequest.url ? `/api/${itemType}/download/${originalRequest.url.split('/').pop()}?name=${encodeURIComponent(dummyItem.name || '')}&artist=${encodeURIComponent(dummyItem.artist || '')}` : null; const queueId = this.generateQueueId(); - const entry = this.createQueueEntry(dummyItem, itemType, prgFile, queueId, requestUrl); + const entry = this.createQueueEntry(dummyItem, itemType, taskId, queueId, requestUrl); entry.retryCount = retryCount; if (lastStatus) { @@ -1719,7 +1715,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) if (lastStatus.parent) { entry.parentInfo = lastStatus.parent; } - this.queueCache[prgFile] = lastStatus; // Cache the last known status + this.queueCache[taskId] = lastStatus; // Cache the last known status this.applyStatusClasses(entry, lastStatus); const logElement = entry.element.querySelector('.log') as HTMLElement | null; @@ -1734,7 +1730,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) this.updateQueueOrder(); this.startMonitoringActiveEntries(); } catch (error) { - console.error("Error loading existing PRG files:", error); + console.error("Error loading existing task files:", error); } } @@ -1792,8 +1788,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) setupPollingInterval(queueId: string) { // Add type console.log(`Setting up polling for ${queueId}`); const entry = this.queueEntries[queueId]; - if (!entry || !entry.prgFile) { - console.warn(`No entry or prgFile for ${queueId}`); + if (!entry || !entry.taskId) { + console.warn(`No entry or taskId for ${queueId}`); return; } @@ -1813,7 +1809,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) this.pollingIntervals[queueId] = intervalId as unknown as number; // Cast to number via unknown } catch (error) { console.error(`Error creating polling for ${queueId}:`, error); - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; if (logElement) { logElement.textContent = `Error with download: ${(error as Error).message}`; // Cast to Error entry.element.classList.add('error'); @@ -1823,13 +1819,13 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) async fetchDownloadStatus(queueId: string) { // Add type const entry = this.queueEntries[queueId]; - if (!entry || !entry.prgFile) { - console.warn(`No entry or prgFile for ${queueId}`); + if (!entry || !entry.taskId) { + console.warn(`No entry or taskId for ${queueId}`); return; } try { - const response = await fetch(`/api/prgs/${entry.prgFile}`); + const response = await fetch(`/api/prgs/${entry.taskId}`); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } @@ -1929,7 +1925,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) console.error(`Error fetching status for ${queueId}:`, error); // Show error in log - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; if (logElement) { logElement.textContent = `Error updating status: ${(error as Error).message}`; // Cast to Error } @@ -2010,7 +2006,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) const STALL_THRESHOLD = 600; // Approx 5 minutes (600 polls * 0.5s/poll) if (detector.count >= STALL_THRESHOLD) { - console.warn(`Download ${queueId} (${entry.prgFile}) appears stalled in real_time state. Metrics: ${detector.lastStatusJson}. Stall count: ${detector.count}. Forcing error.`); + console.warn(`Download ${queueId} (${entry.taskId}) appears stalled in real_time state. Metrics: ${detector.lastStatusJson}. Stall count: ${detector.count}. Forcing error.`); statusData.status = 'error'; statusData.error = 'Download stalled (no progress updates for 5 minutes)'; statusData.can_retry = true; // Allow manual retry for stalled items @@ -2045,7 +2041,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Update log message - but only if we're not handling a track update for an album/playlist // That case is handled separately in updateItemMetadata to ensure we show the right track info - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; if (logElement && status !== 'error' && !(statusData.type === 'track' && statusData.parent && (entry.type === 'album' || entry.type === 'playlist'))) { logElement.textContent = message; @@ -2076,12 +2072,12 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) if (cancelBtn) cancelBtn.style.display = 'none'; // Hide progress bars for errored items - const trackProgressContainer = entry.element.querySelector(`#track-progress-container-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const trackProgressContainer = entry.element.querySelector(`#track-progress-container-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; if (trackProgressContainer) trackProgressContainer.style.display = 'none'; const overallProgressContainer = entry.element.querySelector('.overall-progress-container') as HTMLElement | null; if (overallProgressContainer) overallProgressContainer.style.display = 'none'; // Hide time elapsed for errored items - const timeElapsedContainer = entry.element.querySelector(`#time-elapsed-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const timeElapsedContainer = entry.element.querySelector(`#time-elapsed-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; if (timeElapsedContainer) timeElapsedContainer.style.display = 'none'; // Extract error details @@ -2094,7 +2090,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) console.log(`Error for ${entry.type} download. Can retry: ${!!entry.requestUrl}. Retry URL: ${entry.requestUrl}`); - const errorLogElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; // Use a different variable name + const errorLogElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; // Use a different variable name if (errorLogElement) { // Check errorLogElement let errorMessageElement = errorLogElement.querySelector('.error-message') as HTMLElement | null; @@ -2158,7 +2154,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) } // Cache the status for potential page reloads - this.queueCache[entry.prgFile] = statusData; + this.queueCache[entry.taskId] = statusData; localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); } @@ -2212,8 +2208,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Update real-time progress for track downloads updateRealTimeProgress(entry: QueueEntry, statusData: StatusData) { // Add types // Get track progress bar - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; - const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; + const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; if (trackProgressBar && statusData.progress !== undefined) { // Update track progress bar @@ -2242,8 +2238,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Update progress for single track downloads updateSingleTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types // Get track progress bar and other UI elements - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; const titleElement = entry.element.querySelector('.title') as HTMLElement | null; const artistElement = entry.element.querySelector('.artist') as HTMLElement | null; let progress = 0; // Declare progress here @@ -2348,7 +2344,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) trackProgressBar.setAttribute('aria-valuenow', safeProgress.toString()); // Use string // Make sure progress bar is visible - const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; if (trackProgressContainer) { trackProgressContainer.style.display = 'block'; } @@ -2365,10 +2361,10 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Update progress for multi-track downloads (albums and playlists) updateMultiTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types // Get progress elements - const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; const titleElement = entry.element.querySelector('.title') as HTMLElement | null; const artistElement = entry.element.querySelector('.artist') as HTMLElement | null; let progress = 0; // Declare progress here for this function's scope @@ -2465,7 +2461,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Update the track-level progress bar if (trackProgressBar) { // Make sure progress bar container is visible - const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; if (trackProgressContainer) { trackProgressContainer.style.display = 'block'; } @@ -2641,7 +2637,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) } const serverTasks: any[] = await response.json(); - const localTaskPrgFiles = new Set(Object.values(this.queueEntries).map(entry => entry.prgFile)); + const localTaskPrgFiles = new Set(Object.values(this.queueEntries).map(entry => entry.taskId)); const serverTaskPrgFiles = new Set(serverTasks.map(task => task.task_id)); const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error']; @@ -2654,7 +2650,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) if (terminalStates.includes(lastStatus?.status)) { // If server says it's terminal, and we have it locally, ensure it's cleaned up - const localEntry = Object.values(this.queueEntries).find(e => e.prgFile === taskId); + const localEntry = Object.values(this.queueEntries).find(e => e.taskId === taskId); if (localEntry && !localEntry.hasEnded) { console.log(`Periodic sync: Server task ${taskId} is terminal (${lastStatus.status}), cleaning up local entry.`); // Use a status object for handleDownloadCompletion @@ -2713,7 +2709,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) } } else { // Task exists locally, check if status needs update from server list - const localEntry = Object.values(this.queueEntries).find(e => e.prgFile === taskId); + const localEntry = Object.values(this.queueEntries).find(e => e.taskId === taskId); if (localEntry && lastStatus && JSON.stringify(localEntry.lastStatus) !== JSON.stringify(lastStatus)) { if (!localEntry.hasEnded) { console.log(`Periodic sync: Updating status for existing task ${taskId} from ${localEntry.lastStatus?.status} to ${lastStatus.status}`); @@ -2727,16 +2723,16 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // 2. Remove local tasks that are no longer on the server or are now terminal on server for (const localEntry of Object.values(this.queueEntries)) { - if (!serverTaskPrgFiles.has(localEntry.prgFile)) { + if (!serverTaskPrgFiles.has(localEntry.taskId)) { if (!localEntry.hasEnded) { - console.log(`Periodic sync: Local task ${localEntry.prgFile} not found on server. Assuming completed/cleaned. Removing.`); + console.log(`Periodic sync: Local task ${localEntry.taskId} not found on server. Assuming completed/cleaned. Removing.`); this.cleanupEntry(localEntry.uniqueId); } } else { - const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.prgFile); + const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.taskId); if (serverEquivalent && serverEquivalent.last_status_obj && terminalStates.includes(serverEquivalent.last_status_obj.status)) { if (!localEntry.hasEnded) { - console.log(`Periodic sync: Local task ${localEntry.prgFile} is now terminal on server (${serverEquivalent.last_status_obj.status}). Cleaning up.`); + console.log(`Periodic sync: Local task ${localEntry.taskId} is now terminal on server (${serverEquivalent.last_status_obj.status}). Cleaning up.`); this.handleDownloadCompletion(localEntry, localEntry.uniqueId, serverEquivalent.last_status_obj); } } diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..a64a48e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,44 @@ +# Spotizerr Backend Tests + +This directory contains automated tests for the Spotizerr backend API. + +## Prerequisites + +1. **Running Backend**: Ensure the Spotizerr Flask application is running and accessible at `http://localhost:7171`. You can start it with `python app.py`. + +2. **Python Dependencies**: Install the necessary Python packages for testing. + ```bash + pip install pytest requests python-dotenv + ``` + +3. **Credentials**: These tests require valid Spotify and Deezer credentials. Create a file named `.env` in the root directory of the project (`spotizerr`) and add your credentials to it. The tests will load this file automatically. + + **Example `.env` file:** + ``` + SPOTIFY_API_CLIENT_ID="your_spotify_client_id" + SPOTIFY_API_CLIENT_SECRET="your_spotify_client_secret" + # This should be the full JSON content of your credentials blob as a single line string + SPOTIFY_BLOB_CONTENT='{"username": "your_spotify_username", "password": "your_spotify_password", ...}' + DEEZER_ARL="your_deezer_arl" + ``` + + The tests will automatically use these credentials to create and manage test accounts named `test-spotify-account` and `test-deezer-account`. + +## Running Tests + +To run all tests, navigate to the root directory of the project (`spotizerr`) and run `pytest`: + +```bash +pytest +``` + +To run a specific test file: + +```bash +pytest tests/test_downloads.py +``` + +For more detailed output, use the `-v` (verbose) and `-s` (show print statements) flags: +```bash +pytest -v -s +``` \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8cdc020 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,149 @@ +import pytest +import requests +import time +import os +import json +from dotenv import load_dotenv + +# Load environment variables from .env file in the project root +load_dotenv() + +# --- Environment-based secrets for testing --- +SPOTIFY_API_CLIENT_ID = os.environ.get("SPOTIFY_API_CLIENT_ID", "your_spotify_client_id") +SPOTIFY_API_CLIENT_SECRET = os.environ.get("SPOTIFY_API_CLIENT_SECRET", "your_spotify_client_secret") +SPOTIFY_BLOB_CONTENT_STR = os.environ.get("SPOTIFY_BLOB_CONTENT_STR", '{}') +try: + SPOTIFY_BLOB_CONTENT = json.loads(SPOTIFY_BLOB_CONTENT_STR) +except json.JSONDecodeError: + SPOTIFY_BLOB_CONTENT = {} + +DEEZER_ARL = os.environ.get("DEEZER_ARL", "your_deezer_arl") + +# --- Standard names for test accounts --- +SPOTIFY_ACCOUNT_NAME = "test-spotify-account" +DEEZER_ACCOUNT_NAME = "test-deezer-account" + + +@pytest.fixture(scope="session") +def base_url(): + """Provides the base URL for the API tests.""" + return "http://localhost:7171/api" + + +def wait_for_task(base_url, task_id, timeout=600): + """ + Waits for a Celery task to reach a terminal state (complete, error, etc.). + Polls the progress endpoint and prints status updates. + """ + print(f"\n--- Waiting for task {task_id} (timeout: {timeout}s) ---") + start_time = time.time() + while time.time() - start_time < timeout: + try: + response = requests.get(f"{base_url}/prgs/{task_id}") + if response.status_code == 404: + time.sleep(1) + continue + + response.raise_for_status() # Raise an exception for bad status codes + + statuses = response.json() + if not statuses: + time.sleep(1) + continue + + last_status = statuses[-1] + status = last_status.get("status") + + # More verbose logging for debugging during tests + message = last_status.get('message', '') + track = last_status.get('track', '') + progress = last_status.get('overall_progress', '') + print(f"Task {task_id} | Status: {status:<12} | Progress: {progress or 'N/A':>3}% | Track: {track:<30} | Message: {message}") + + if status in ["complete", "ERROR", "cancelled", "ERROR_RETRIED", "ERROR_AUTO_CLEANED"]: + print(f"--- Task {task_id} finished with status: {status} ---") + return last_status + + time.sleep(2) + except requests.exceptions.RequestException as e: + print(f"Warning: Request to fetch task status for {task_id} failed: {e}. Retrying...") + time.sleep(5) + + raise TimeoutError(f"Task {task_id} did not complete within {timeout} seconds.") + + +@pytest.fixture(scope="session") +def task_waiter(base_url): + """Provides a fixture that returns the wait_for_task helper function.""" + def _waiter(task_id, timeout=600): + return wait_for_task(base_url, task_id, timeout) + return _waiter + + +@pytest.fixture(scope="session", autouse=True) +def setup_credentials_for_tests(base_url): + """ + A session-wide, automatic fixture to set up all necessary credentials. + It runs once before any tests, and tears down the credentials after all tests are complete. + """ + print("\n--- Setting up credentials for test session ---") + + print("\n--- DEBUGGING CREDENTIALS ---") + print(f"SPOTIFY_API_CLIENT_ID: {SPOTIFY_API_CLIENT_ID}") + print(f"SPOTIFY_API_CLIENT_SECRET: {SPOTIFY_API_CLIENT_SECRET}") + print(f"DEEZER_ARL: {DEEZER_ARL}") + print(f"SPOTIFY_BLOB_CONTENT {SPOTIFY_BLOB_CONTENT}") + print("--- END DEBUGGING ---\n") + + # Skip all tests if secrets are not provided in the environment + if SPOTIFY_API_CLIENT_ID == "your_spotify_client_id" or \ + SPOTIFY_API_CLIENT_SECRET == "your_spotify_client_secret" or \ + not SPOTIFY_BLOB_CONTENT or \ + DEEZER_ARL == "your_deezer_arl": + pytest.skip("Required credentials not provided in .env file or environment. Skipping credential-dependent tests.") + + # 1. Set global Spotify API creds + data = {"client_id": SPOTIFY_API_CLIENT_ID, "client_secret": SPOTIFY_API_CLIENT_SECRET} + response = requests.put(f"{base_url}/credentials/spotify_api_config", json=data) + if response.status_code != 200: + pytest.fail(f"Failed to set global Spotify API creds: {response.text}") + print("Global Spotify API credentials set.") + + # 2. Delete any pre-existing test credentials to ensure a clean state + requests.delete(f"{base_url}/credentials/spotify/{SPOTIFY_ACCOUNT_NAME}") + requests.delete(f"{base_url}/credentials/deezer/{DEEZER_ACCOUNT_NAME}") + print("Cleaned up any old test credentials.") + + # 3. Create Deezer credential + data = {"name": DEEZER_ACCOUNT_NAME, "arl": DEEZER_ARL, "region": "US"} + response = requests.post(f"{base_url}/credentials/deezer/{DEEZER_ACCOUNT_NAME}", json=data) + if response.status_code != 201: + pytest.fail(f"Failed to create Deezer credential: {response.text}") + print("Deezer test credential created.") + + # 4. Create Spotify credential + data = {"name": SPOTIFY_ACCOUNT_NAME, "blob_content": SPOTIFY_BLOB_CONTENT, "region": "US"} + response = requests.post(f"{base_url}/credentials/spotify/{SPOTIFY_ACCOUNT_NAME}", json=data) + if response.status_code != 201: + pytest.fail(f"Failed to create Spotify credential: {response.text}") + print("Spotify test credential created.") + + # 5. Set main config to use these accounts for downloads + config_payload = { + "spotify": SPOTIFY_ACCOUNT_NAME, + "deezer": DEEZER_ACCOUNT_NAME, + } + response = requests.post(f"{base_url}/config", json=config_payload) + if response.status_code != 200: + pytest.fail(f"Failed to set main config for tests: {response.text}") + print("Main config set to use test credentials.") + + yield # This is where the tests will run + + # --- Teardown --- + print("\n--- Tearing down test credentials ---") + response = requests.delete(f"{base_url}/credentials/spotify/{SPOTIFY_ACCOUNT_NAME}") + assert response.status_code in [200, 404] + response = requests.delete(f"{base_url}/credentials/deezer/{DEEZER_ACCOUNT_NAME}") + assert response.status_code in [200, 404] + print("Test credentials deleted.") \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..00f81bf --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,94 @@ +import requests +import pytest + +@pytest.fixture +def reset_config(base_url): + """A fixture to ensure the main config is reset after a test case.""" + response = requests.get(f"{base_url}/config") + assert response.status_code == 200 + original_config = response.json() + yield + response = requests.post(f"{base_url}/config", json=original_config) + assert response.status_code == 200 + +def test_get_main_config(base_url): + """Tests if the main configuration can be retrieved.""" + response = requests.get(f"{base_url}/config") + assert response.status_code == 200 + config = response.json() + assert "service" in config + assert "maxConcurrentDownloads" in config + assert "spotify" in config # Should be set by conftest + assert "deezer" in config # Should be set by conftest + +def test_update_main_config(base_url, reset_config): + """Tests updating various fields in the main configuration.""" + new_settings = { + "maxConcurrentDownloads": 5, + "spotifyQuality": "HIGH", + "deezerQuality": "FLAC", + "customDirFormat": "%artist%/%album%", + "customTrackFormat": "%tracknum% %title%", + "save_cover": False, + "fallback": True, + } + + response = requests.post(f"{base_url}/config", json=new_settings) + assert response.status_code == 200 + updated_config = response.json() + + for key, value in new_settings.items(): + assert updated_config[key] == value + +def test_get_watch_config(base_url): + """Tests if the watch-specific configuration can be retrieved.""" + response = requests.get(f"{base_url}/config/watch") + assert response.status_code == 200 + config = response.json() + assert "delay_between_playlists_seconds" in config + assert "delay_between_artists_seconds" in config + +def test_update_watch_config(base_url): + """Tests updating the watch-specific configuration.""" + response = requests.get(f"{base_url}/config/watch") + original_config = response.json() + + new_settings = { + "delay_between_playlists_seconds": 120, + "delay_between_artists_seconds": 240, + "auto_add_new_releases_to_queue": False, + } + + response = requests.post(f"{base_url}/config/watch", json=new_settings) + assert response.status_code == 200 + updated_config = response.json() + + for key, value in new_settings.items(): + assert updated_config[key] == value + + # Revert to original + requests.post(f"{base_url}/config/watch", json=original_config) + +def test_update_conversion_config(base_url, reset_config): + """ + Iterates through all supported conversion formats and bitrates, + updating the config and verifying the changes for each combination. + """ + conversion_formats = ["mp3", "flac", "ogg", "opus", "m4a"] + bitrates = { + "mp3": ["320", "256", "192", "128"], + "ogg": ["500", "320", "192", "160"], + "opus": ["256", "192", "128", "96"], + "m4a": ["320k", "256k", "192k", "128k"], + "flac": [None] # Bitrate is not applicable for FLAC + } + + for format in conversion_formats: + for br in bitrates.get(format, [None]): + print(f"Testing conversion config: format={format}, bitrate={br}") + new_settings = {"convertTo": format, "bitrate": br} + response = requests.post(f"{base_url}/config", json=new_settings) + assert response.status_code == 200 + updated_config = response.json() + assert updated_config["convertTo"] == format + assert updated_config["bitrate"] == br \ No newline at end of file diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..6cca963 --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,128 @@ +import requests +import pytest + +# URLs provided by the user for testing +SPOTIFY_TRACK_URL = "https://open.spotify.com/track/1Cts4YV9aOXVAP3bm3Ro6r" +SPOTIFY_ALBUM_URL = "https://open.spotify.com/album/4K0JVP5veNYTVI6IMamlla" +SPOTIFY_PLAYLIST_URL = "https://open.spotify.com/playlist/26CiMxIxdn5WhXyccMCPOB" +SPOTIFY_ARTIST_URL = "https://open.spotify.com/artist/7l6cdPhOLYO7lehz5xfzLV" + +# Corresponding IDs extracted from URLs +TRACK_ID = SPOTIFY_TRACK_URL.split('/')[-1].split('?')[0] +ALBUM_ID = SPOTIFY_ALBUM_URL.split('/')[-1].split('?')[0] +PLAYLIST_ID = SPOTIFY_PLAYLIST_URL.split('/')[-1].split('?')[0] +ARTIST_ID = SPOTIFY_ARTIST_URL.split('/')[-1].split('?')[0] + +@pytest.fixture +def reset_config(base_url): + """Fixture to reset the main config after a test to avoid side effects.""" + response = requests.get(f"{base_url}/config") + original_config = response.json() + yield + requests.post(f"{base_url}/config", json=original_config) + +def test_download_track_spotify_only(base_url, task_waiter, reset_config): + """Tests downloading a single track from Spotify with real-time download enabled.""" + print("\n--- Testing Spotify-only track download ---") + config_payload = { + "service": "spotify", + "fallback": False, + "realTime": True, + "spotifyQuality": "NORMAL" # Simulating free account quality + } + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/track/download/{TRACK_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + final_status = task_waiter(task_id) + assert final_status["status"] == "complete", f"Task failed: {final_status.get('error')}" + +def test_download_album_spotify_only(base_url, task_waiter, reset_config): + """Tests downloading a full album from Spotify with real-time download enabled.""" + print("\n--- Testing Spotify-only album download ---") + config_payload = {"service": "spotify", "fallback": False, "realTime": True, "spotifyQuality": "NORMAL"} + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/album/download/{ALBUM_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + final_status = task_waiter(task_id, timeout=900) + assert final_status["status"] == "complete", f"Task failed: {final_status.get('error')}" + +def test_download_playlist_spotify_only(base_url, task_waiter, reset_config): + """Tests downloading a full playlist from Spotify with real-time download enabled.""" + print("\n--- Testing Spotify-only playlist download ---") + config_payload = {"service": "spotify", "fallback": False, "realTime": True, "spotifyQuality": "NORMAL"} + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/playlist/download/{PLAYLIST_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + final_status = task_waiter(task_id, timeout=1200) + assert final_status["status"] == "complete", f"Task failed: {final_status.get('error')}" + +def test_download_artist_spotify_only(base_url, task_waiter, reset_config): + """Tests queuing downloads for an artist's entire discography from Spotify.""" + print("\n--- Testing Spotify-only artist download ---") + config_payload = {"service": "spotify", "fallback": False, "realTime": True, "spotifyQuality": "NORMAL"} + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/artist/download/{ARTIST_ID}?album_type=album,single") + assert response.status_code == 202 + response_data = response.json() + queued_albums = response_data.get("successfully_queued_albums", []) + assert len(queued_albums) > 0, "No albums were queued for the artist." + + for album in queued_albums: + task_id = album["task_id"] + print(f"--- Waiting for artist album: {album['name']} ({task_id}) ---") + final_status = task_waiter(task_id, timeout=900) + assert final_status["status"] == "complete", f"Artist album task {album['name']} failed: {final_status.get('error')}" + +def test_download_track_with_fallback(base_url, task_waiter, reset_config): + """Tests downloading a Spotify track with Deezer fallback enabled.""" + print("\n--- Testing track download with Deezer fallback ---") + config_payload = { + "service": "spotify", + "fallback": True, + "deezerQuality": "MP3_320" # Simulating higher quality from Deezer free + } + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/track/download/{TRACK_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + final_status = task_waiter(task_id) + assert final_status["status"] == "complete", f"Task failed: {final_status.get('error')}" + +@pytest.mark.parametrize("format,bitrate", [ + ("mp3", "320"), ("mp3", "128"), + ("flac", None), + ("ogg", "160"), + ("opus", "128"), + ("m4a", "128k") +]) +def test_download_with_conversion(base_url, task_waiter, reset_config, format, bitrate): + """Tests downloading a track with various conversion formats and bitrates.""" + print(f"\n--- Testing conversion: {format} @ {bitrate or 'default'} ---") + config_payload = { + "service": "spotify", + "fallback": False, + "realTime": True, + "spotifyQuality": "NORMAL", + "convertTo": format, + "bitrate": bitrate + } + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/track/download/{TRACK_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + final_status = task_waiter(task_id) + assert final_status["status"] == "complete", f"Download failed for format {format} bitrate {bitrate}: {final_status.get('error')}" \ No newline at end of file diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..bd6228b --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,61 @@ +import requests +import pytest +import time + +TRACK_ID = "1Cts4YV9aOXVAP3bm3Ro6r" # Use a known, short track + +@pytest.fixture +def reset_config(base_url): + """Fixture to reset the main config after a test.""" + response = requests.get(f"{base_url}/config") + original_config = response.json() + yield + requests.post(f"{base_url}/config", json=original_config) + +def test_history_logging_and_filtering(base_url, task_waiter, reset_config): + """ + Tests if a completed download appears in the history and + verifies that history filtering works correctly. + """ + # First, complete a download task to ensure there's a history entry + config_payload = {"service": "spotify", "fallback": False, "realTime": True} + requests.post(f"{base_url}/config", json=config_payload) + response = requests.get(f"{base_url}/track/download/{TRACK_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + task_waiter(task_id) # Wait for the download to complete + + # Give a moment for history to be written if it's asynchronous + time.sleep(2) + + # 1. Get all history and check if our task is present + print("\n--- Verifying task appears in general history ---") + response = requests.get(f"{base_url}/history") + assert response.status_code == 200 + history_data = response.json() + assert "entries" in history_data + assert "total" in history_data + assert history_data["total"] > 0 + + # Find our specific task in the history + history_entry = next((entry for entry in history_data["entries"] if entry['task_id'] == task_id), None) + assert history_entry is not None, f"Task {task_id} not found in download history." + assert history_entry["status_final"] == "COMPLETED" + + # 2. Test filtering for COMPLETED tasks + print("\n--- Verifying history filtering for COMPLETED status ---") + response = requests.get(f"{base_url}/history?filters[status_final]=COMPLETED") + assert response.status_code == 200 + completed_history = response.json() + assert completed_history["total"] > 0 + assert any(entry['task_id'] == task_id for entry in completed_history["entries"]) + assert all(entry['status_final'] == 'COMPLETED' for entry in completed_history["entries"]) + + # 3. Test filtering for an item name + print(f"\n--- Verifying history filtering for item_name: {history_entry['item_name']} ---") + item_name_query = requests.utils.quote(history_entry['item_name']) + response = requests.get(f"{base_url}/history?filters[item_name]={item_name_query}") + assert response.status_code == 200 + named_history = response.json() + assert named_history["total"] > 0 + assert any(entry['task_id'] == task_id for entry in named_history["entries"]) \ No newline at end of file diff --git a/tests/test_prgs.py b/tests/test_prgs.py new file mode 100644 index 0000000..39dd902 --- /dev/null +++ b/tests/test_prgs.py @@ -0,0 +1,93 @@ +import requests +import pytest +import time + +# Use a known, short track for quick tests +TRACK_ID = "1Cts4YV9aOXVAP3bm3Ro6r" +# Use a long playlist to ensure there's time to cancel it +LONG_PLAYLIST_ID = "6WsyUEITURbQXZsqtEewb1" # Today's Top Hits on Spotify + +@pytest.fixture +def reset_config(base_url): + """Fixture to reset the main config after a test.""" + response = requests.get(f"{base_url}/config") + original_config = response.json() + yield + requests.post(f"{base_url}/config", json=original_config) + +def test_list_tasks(base_url, reset_config): + """Tests listing all active tasks.""" + config_payload = {"service": "spotify", "fallback": False, "realTime": True} + requests.post(f"{base_url}/config", json=config_payload) + + # Start a task + response = requests.get(f"{base_url}/track/download/{TRACK_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + # Check the list to see if our task appears + response = requests.get(f"{base_url}/prgs/list") + assert response.status_code == 200 + tasks = response.json() + assert isinstance(tasks, list) + assert any(t['task_id'] == task_id for t in tasks) + + # Clean up by cancelling the task + requests.post(f"{base_url}/prgs/cancel/{task_id}") + +def test_get_task_progress_and_log(base_url, task_waiter, reset_config): + """Tests getting progress for a running task and retrieving its log after completion.""" + config_payload = {"service": "spotify", "fallback": False, "realTime": True} + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/track/download/{TRACK_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + # Poll progress a few times while it's running to check the endpoint + for _ in range(3): + time.sleep(1) + res = requests.get(f"{base_url}/prgs/{task_id}") + if res.status_code == 200 and res.json(): + statuses = res.json() + assert isinstance(statuses, list) + assert "status" in statuses[-1] + break + else: + pytest.fail("Could not get a valid task status in time.") + + # Wait for completion + final_status = task_waiter(task_id) + assert final_status["status"] == "complete" + + # After completion, check the task log endpoint + res = requests.get(f"{base_url}/prgs/{task_id}?log=true") + assert res.status_code == 200 + log_data = res.json() + assert "task_log" in log_data + assert len(log_data["task_log"]) > 0 + assert "status" in log_data["task_log"][0] + +def test_cancel_task(base_url, reset_config): + """Tests cancelling a task shortly after it has started.""" + config_payload = {"service": "spotify", "fallback": False, "realTime": True} + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/playlist/download/{LONG_PLAYLIST_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + # Give it a moment to ensure it has started processing + time.sleep(3) + + # Cancel the task + response = requests.post(f"{base_url}/prgs/cancel/{task_id}") + assert response.status_code == 200 + assert response.json()["status"] == "cancelled" + + # Check the final status to confirm it's marked as cancelled + time.sleep(2) # Allow time for the final status to propagate + res = requests.get(f"{base_url}/prgs/{task_id}") + assert res.status_code == 200 + last_status = res.json()[-1] + assert last_status["status"] == "cancelled" \ No newline at end of file diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..0d6072f --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,35 @@ +import requests +import pytest + +def test_search_spotify_artist(base_url): + """Tests searching for an artist on Spotify.""" + response = requests.get(f"{base_url}/search?q=Daft+Punk&search_type=artist") + assert response.status_code == 200 + results = response.json() + assert "items" in results + assert len(results["items"]) > 0 + assert "Daft Punk" in results["items"][0]["name"] + +def test_search_spotify_track(base_url): + """Tests searching for a track on Spotify.""" + response = requests.get(f"{base_url}/search?q=Get+Lucky&search_type=track") + assert response.status_code == 200 + results = response.json() + assert "items" in results + assert len(results["items"]) > 0 + +def test_search_deezer_track(base_url): + """Tests searching for a track on Deezer.""" + response = requests.get(f"{base_url}/search?q=Instant+Crush&search_type=track") + assert response.status_code == 200 + results = response.json() + assert "items" in results + assert len(results["items"]) > 0 + +def test_search_deezer_album(base_url): + """Tests searching for an album on Deezer.""" + response = requests.get(f"{base_url}/search?q=Random+Access+Memories&search_type=album") + assert response.status_code == 200 + results = response.json() + assert "items" in results + assert len(results["items"]) > 0 \ No newline at end of file diff --git a/tests/test_watch.py b/tests/test_watch.py new file mode 100644 index 0000000..fba8a31 --- /dev/null +++ b/tests/test_watch.py @@ -0,0 +1,117 @@ +import requests +import pytest +import time + +SPOTIFY_PLAYLIST_ID = "26CiMxIxdn5WhXyccMCPOB" +SPOTIFY_ARTIST_ID = "7l6cdPhOLYO7lehz5xfzLV" + +@pytest.fixture(autouse=True) +def setup_and_cleanup_watch_tests(base_url): + """ + A fixture that enables watch mode, cleans the watchlist before each test, + and then restores original state and cleans up after each test. + """ + # Get original watch config to restore it later + response = requests.get(f"{base_url}/config/watch") + assert response.status_code == 200 + original_config = response.json() + + # Enable watch mode for testing if it's not already + if not original_config.get("enabled"): + response = requests.post(f"{base_url}/config/watch", json={"enabled": True}) + assert response.status_code == 200 + + # Cleanup any existing watched items before the test + requests.delete(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + requests.delete(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + + yield + + # Cleanup watched items created during the test + requests.delete(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + requests.delete(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + + # Restore original watch config + response = requests.post(f"{base_url}/config/watch", json=original_config) + assert response.status_code == 200 + +def test_add_and_list_playlist_to_watch(base_url): + """Tests adding a playlist to the watch list and verifying it appears in the list.""" + response = requests.put(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + assert response.status_code == 200 + assert "Playlist added to watch list" in response.json()["message"] + + # Verify it's in the watched list + response = requests.get(f"{base_url}/playlist/watch/list") + assert response.status_code == 200 + watched_playlists = response.json() + assert any(p['spotify_id'] == SPOTIFY_PLAYLIST_ID for p in watched_playlists) + +def test_add_and_list_artist_to_watch(base_url): + """Tests adding an artist to the watch list and verifying it appears in the list.""" + response = requests.put(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + assert response.status_code == 200 + assert "Artist added to watch list" in response.json()["message"] + + # Verify it's in the watched list + response = requests.get(f"{base_url}/artist/watch/list") + assert response.status_code == 200 + watched_artists = response.json() + assert any(a['spotify_id'] == SPOTIFY_ARTIST_ID for a in watched_artists) + +def test_trigger_playlist_check(base_url): + """Tests the endpoint for manually triggering a check on a watched playlist.""" + # First, add the playlist to the watch list + requests.put(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + + # Trigger the check + response = requests.post(f"{base_url}/playlist/watch/trigger_check/{SPOTIFY_PLAYLIST_ID}") + assert response.status_code == 200 + assert "Check triggered for playlist" in response.json()["message"] + + # A full verification would require inspecting the database or new tasks, + # but for an API test, confirming the trigger endpoint responds correctly is the key goal. + print("Playlist check triggered. Note: This does not verify new downloads were queued.") + +def test_trigger_artist_check(base_url): + """Tests the endpoint for manually triggering a check on a watched artist.""" + # First, add the artist to the watch list + requests.put(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + + # Trigger the check + response = requests.post(f"{base_url}/artist/watch/trigger_check/{SPOTIFY_ARTIST_ID}") + assert response.status_code == 200 + assert "Check triggered for artist" in response.json()["message"] + print("Artist check triggered. Note: This does not verify new downloads were queued.") + +def test_remove_playlist_from_watch(base_url): + """Tests removing a playlist from the watch list.""" + # Add the playlist first to ensure it exists + requests.put(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + + # Now, remove it + response = requests.delete(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + assert response.status_code == 200 + assert "Playlist removed from watch list" in response.json()["message"] + + # Verify it's no longer in the list + response = requests.get(f"{base_url}/playlist/watch/list") + assert response.status_code == 200 + watched_playlists = response.json() + assert not any(p['spotify_id'] == SPOTIFY_PLAYLIST_ID for p in watched_playlists) + +def test_remove_artist_from_watch(base_url): + """Tests removing an artist from the watch list.""" + # Add the artist first to ensure it exists + requests.put(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + + # Now, remove it + response = requests.delete(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + assert response.status_code == 200 + assert "Artist removed from watch list" in response.json()["message"] + + # Verify it's no longer in the list + response = requests.get(f"{base_url}/artist/watch/list") + assert response.status_code == 200 + watched_artists = response.json() + assert not any(a['spotify_id'] == SPOTIFY_ARTIST_ID for a in watched_artists) \ No newline at end of file