// Tests for downloads store // TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017 | UT-010, UT-024 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { get } from "svelte/store"; // Mock Tauri APIs const mockInvoke = vi.fn(); const mockListen = vi.fn(); vi.mock("@tauri-apps/api/core", () => ({ invoke: mockInvoke, })); vi.mock("@tauri-apps/api/event", () => ({ listen: mockListen, })); describe("downloads store", () => { let eventHandler: ((event: { payload: unknown }) => void) | null = null; beforeEach(() => { vi.clearAllMocks(); // Reset the invoke mock to clear any remaining queued return values mockInvoke.mockReset(); // Capture the event handler when listen is called mockListen.mockImplementation( (_event: string, handler: (event: { payload: unknown }) => void) => { eventHandler = handler; return Promise.resolve(() => {}); }, ); }); afterEach(async () => { // Clean up event listeners const { cleanupDownloadEvents } = await import("./downloads"); cleanupDownloadEvents(); eventHandler = null; // Clear all mocks vi.clearAllMocks(); mockInvoke.mockReset(); mockListen.mockReset(); }); describe("initial state", () => { it("should have empty downloads initially", async () => { const { downloads } = await import("./downloads"); const state = get(downloads); expect(state.downloads).toEqual({}); expect(state.stats.activeCount).toBe(0); expect(state.stats.queuedCount).toBe(0); }); }); describe("downloadItem", () => { it("should call invoke with correct parameters", async () => { const { downloads } = await import("./downloads"); mockInvoke .mockResolvedValueOnce(123) // download_item returns ID .mockResolvedValueOnce({ downloads: [], stats: { total: 0, activeCount: 0, queuedCount: 0, completedCount: 0, failedCount: 0, pausedCount: 0, }, }); // get_downloads returns empty const downloadId = await downloads.downloadItem( "item-1", "user-1", "/path/to/file.mp3", "audio/mpeg", 10, ); expect(mockInvoke).toHaveBeenCalledWith("download_item", { request: { itemId: "item-1", userId: "user-1", filePath: "/path/to/file.mp3", mimeType: "audio/mpeg", priority: 10, itemName: null, artistName: null, albumName: null, expectedSize: null, }, }); expect(downloadId).toBe(123); }); it("should refresh downloads after queuing", async () => { const { downloads } = await import("./downloads"); mockInvoke.mockResolvedValueOnce(123).mockResolvedValueOnce({ downloads: [ { id: 123, itemId: "item-1", userId: "user-1", filePath: "/path/to/file.mp3", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 10, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 1, activeCount: 0, queuedCount: 1, completedCount: 0, failedCount: 0, pausedCount: 0, }, }); await downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3"); expect(mockInvoke).toHaveBeenCalledWith("get_downloads", { userId: "user-1", statusFilter: null, }); const state = get(downloads); expect(state.downloads[123]).toBeDefined(); expect(state.stats.queuedCount).toBe(1); }); }); describe("downloadAlbum", () => { it("should call invoke with correct parameters", async () => { const { downloads } = await import("./downloads"); mockInvoke .mockResolvedValueOnce([1, 2, 3]) // download_album returns IDs .mockResolvedValueOnce({ downloads: [], stats: { total: 0, activeCount: 0, queuedCount: 0, completedCount: 0, failedCount: 0, pausedCount: 0, }, }); // get_downloads const ids = await downloads.downloadAlbum("handle-1", "album-1", "user-1", "/base/path"); expect(mockInvoke).toHaveBeenCalledWith("download_album", { handle: "handle-1", albumId: "album-1", userId: "user-1", basePath: "/base/path", }); expect(ids).toEqual([1, 2, 3]); }); }); describe("pause/resume/cancel", () => { it("should call pause_download with correct ID", async () => { const { downloads } = await import("./downloads"); mockInvoke.mockResolvedValueOnce(undefined); await downloads.pause(123); expect(mockInvoke).toHaveBeenCalledWith("pause_download", { downloadId: 123 }); }); it("should call resume_download with correct ID", async () => { const { downloads } = await import("./downloads"); mockInvoke.mockResolvedValueOnce(undefined); await downloads.resume(123); expect(mockInvoke).toHaveBeenCalledWith("resume_download", { downloadId: 123 }); }); it("should call cancel_download with correct ID", async () => { const { downloads } = await import("./downloads"); mockInvoke.mockResolvedValueOnce(undefined); await downloads.cancel(123); expect(mockInvoke).toHaveBeenCalledWith("cancel_download", { downloadId: 123 }); }); }); describe("delete", () => { it("should call delete_download and remove from store", async () => { const { downloads } = await import("./downloads"); // First add a download via refresh mockInvoke.mockResolvedValueOnce({ downloads: [ { id: 123, itemId: "item-1", userId: "user-1", filePath: "/path/to/file.mp3", status: "completed", progress: 1.0, bytesDownloaded: 1000, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 1, activeCount: 0, queuedCount: 0, completedCount: 1, failedCount: 0, pausedCount: 0, }, }); await downloads.refresh("user-1"); expect(get(downloads).downloads[123]).toBeDefined(); // Now delete mockInvoke.mockResolvedValueOnce(undefined); await downloads.delete(123); expect(mockInvoke).toHaveBeenCalledWith("delete_download", { downloadId: 123 }); expect(get(downloads).downloads[123]).toBeUndefined(); }); }); describe("refresh", () => { it("should update store with downloads from backend", async () => { const { downloads } = await import("./downloads"); mockInvoke.mockResolvedValueOnce({ downloads: [ { id: 1, itemId: "item-1", userId: "user-1", filePath: "/path/1.mp3", status: "downloading", progress: 0.5, bytesDownloaded: 500, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 2, itemId: "item-2", userId: "user-1", filePath: "/path/2.mp3", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "2024-01-01T00:00:01Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 3, itemId: "item-3", userId: "user-1", filePath: "/path/3.mp3", status: "completed", progress: 1.0, bytesDownloaded: 1000, queuedAt: "2024-01-01T00:00:02Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 3, activeCount: 1, queuedCount: 1, completedCount: 1, failedCount: 0, pausedCount: 0, }, }); await downloads.refresh("user-1"); const state = get(downloads); expect(Object.keys(state.downloads).length).toBe(3); expect(state.stats.activeCount).toBe(1); // 1 downloading expect(state.stats.queuedCount).toBe(1); // 1 pending }); // The Transfers view shows only in-flight rows; a completed transfer must // NOT appear there (it lives in Downloaded). Mirrors the /downloads page's // `transfers` derivation: active + pending + failed. // TRACES: UR-055 | DR-084 | UT-052 it("transfers set excludes completed downloads", async () => { const { downloads, activeDownloads, pendingDownloads, failedDownloads } = await import("./downloads"); mockInvoke.mockResolvedValueOnce({ downloads: [ { id: 1, itemId: "a", userId: "u", filePath: "/a", status: "downloading", progress: 0.5, bytesDownloaded: 5, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 2, itemId: "b", userId: "u", filePath: "/b", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 3, itemId: "c", userId: "u", filePath: "/c", status: "completed", progress: 1, bytesDownloaded: 9, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 4, itemId: "d", userId: "u", filePath: "/d", status: "failed", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 1, priority: 0, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 4, activeCount: 1, queuedCount: 1, completedCount: 1, failedCount: 1, pausedCount: 0, }, }); await downloads.refresh("u"); const transfers = get(activeDownloads) .concat(get(pendingDownloads)) .concat(get(failedDownloads)); const ids = transfers.map((d) => d.id).sort(); expect(ids).toEqual([1, 2, 4]); expect(transfers.some((d) => d.status === "completed")).toBe(false); }); it("should support status filter", async () => { const { downloads } = await import("./downloads"); mockInvoke.mockResolvedValueOnce({ downloads: [], stats: { total: 0, activeCount: 0, queuedCount: 0, completedCount: 0, failedCount: 0, pausedCount: 0, }, }); await downloads.refresh("user-1", ["pending", "downloading"]); expect(mockInvoke).toHaveBeenCalledWith("get_downloads", { userId: "user-1", statusFilter: ["pending", "downloading"], }); }); }); describe("event handling", () => { it("should initialize event listener via initDownloadEvents", async () => { const { initDownloadEvents } = await import("./downloads"); await initDownloadEvents(); expect(mockListen).toHaveBeenCalledWith("download-event", expect.any(Function)); expect(eventHandler).not.toBeNull(); }); it("should handle started event and refresh", async () => { const { downloads, initDownloadEvents } = await import("./downloads"); // First add a pending download mockInvoke.mockResolvedValueOnce({ downloads: [ { id: 123, itemId: "item-1", userId: "user-1", filePath: "/path/to/file.mp3", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 1, activeCount: 0, queuedCount: 1, completedCount: 0, failedCount: 0, pausedCount: 0, }, }); await downloads.refresh("user-1"); // Initialize event listener await initDownloadEvents(); expect(eventHandler).not.toBeNull(); // Mock refresh call that will happen when event is handled mockInvoke.mockResolvedValueOnce({ downloads: [ { id: 123, itemId: "item-1", userId: "user-1", filePath: "/path/to/file.mp3", status: "downloading", progress: 0, bytesDownloaded: 0, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 1, activeCount: 1, queuedCount: 0, completedCount: 0, failedCount: 0, pausedCount: 0, }, }); // Simulate started event eventHandler!({ payload: { type: "started", downloadId: 123, itemId: "item-1", }, }); // Wait for async operations to complete await new Promise((resolve) => setTimeout(resolve, 100)); const state = get(downloads); expect(state.downloads[123].status).toBe("downloading"); }); it("should handle completed event and refresh", async () => { const { downloads, initDownloadEvents } = await import("./downloads"); mockInvoke.mockResolvedValueOnce({ downloads: [ { id: 123, itemId: "item-1", userId: "user-1", filePath: "/path/to/file.mp3", status: "downloading", progress: 0.99, bytesDownloaded: 990000, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 1, activeCount: 1, queuedCount: 0, completedCount: 0, failedCount: 0, pausedCount: 0, }, }); await downloads.refresh("user-1"); // Initialize event listener await initDownloadEvents(); // Mock the mark_download_completed invoke call mockInvoke.mockResolvedValueOnce(undefined); // Simulate completed event eventHandler!({ payload: { type: "completed", downloadId: 123, itemId: "item-1", filePath: "/path/to/file.mp3", totalBytes: 1000000, }, }); // Wait for async operations to complete await new Promise((resolve) => setTimeout(resolve, 100)); const state = get(downloads); expect(state.downloads[123].status).toBe("completed"); }); it("should handle failed event and refresh", async () => { const { downloads, initDownloadEvents } = await import("./downloads"); // Setup mock to return downloading download when refresh is called mockInvoke.mockImplementation((command: string) => { if (command === "get_downloads") { return Promise.resolve({ downloads: [ { id: 123, itemId: "item-1", userId: "user-1", filePath: "/path/to/file.mp3", status: "downloading", progress: 0.5, bytesDownloaded: 500000, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 1, activeCount: 1, queuedCount: 0, completedCount: 0, failedCount: 0, pausedCount: 0, }, }); } else if (command === "mark_download_failed") { return Promise.resolve(undefined); } return Promise.reject(new Error(`Unexpected command: ${command}`)); }); await downloads.refresh("user-1"); // Initialize event listener await initDownloadEvents(); // Simulate failed event eventHandler!({ payload: { type: "failed", downloadId: 123, itemId: "item-1", error: "Network timeout", }, }); // Wait for async operations to complete await new Promise((resolve) => setTimeout(resolve, 100)); const state = get(downloads); expect(state.downloads[123].status).toBe("failed"); expect(state.downloads[123].errorMessage).toBe("Network timeout"); }); it("should handle cancelled event and refresh", async () => { const { downloads, initDownloadEvents } = await import("./downloads"); mockInvoke.mockResolvedValueOnce({ downloads: [ { id: 123, itemId: "item-1", userId: "user-1", filePath: "/path/to/file.mp3", status: "downloading", progress: 0.5, bytesDownloaded: 500000, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 1, activeCount: 1, queuedCount: 0, completedCount: 0, failedCount: 0, pausedCount: 0, }, }); await downloads.refresh("user-1"); // Initialize event listener await initDownloadEvents(); // Simulate cancelled event // Note: The cancelled event handler does NOT call refresh, it only removes from store eventHandler!({ payload: { type: "cancelled", downloadId: 123, itemId: "item-1", }, }); // Wait for async operations to complete await new Promise((resolve) => setTimeout(resolve, 100)); const state = get(downloads); expect(state.downloads[123]).toBeUndefined(); }); }); describe("derived stores", () => { it("activeDownloads should filter downloading status", async () => { const { downloads, activeDownloads } = await import("./downloads"); mockInvoke.mockImplementation((command: string) => { if (command === "get_downloads") { return Promise.resolve({ downloads: [ { id: 1, itemId: "item-1", userId: "user-1", filePath: "/path/1.mp3", status: "downloading", progress: 0.5, bytesDownloaded: 500, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 2, itemId: "item-2", userId: "user-1", filePath: "/path/2.mp3", status: "completed", progress: 1.0, bytesDownloaded: 1000, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 3, itemId: "item-3", userId: "user-1", filePath: "/path/3.mp3", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 3, activeCount: 1, queuedCount: 1, completedCount: 1, failedCount: 0, pausedCount: 0, }, }); } return Promise.reject(new Error(`Unexpected command: ${command}`)); }); await downloads.refresh("user-1"); const active = get(activeDownloads); expect(active.length).toBe(1); expect(active[0].id).toBe(1); expect(active[0].status).toBe("downloading"); }); it("completedDownloads should filter completed status", async () => { const { downloads, completedDownloads } = await import("./downloads"); mockInvoke.mockImplementation((command: string) => { if (command === "get_downloads") { return Promise.resolve({ downloads: [ { id: 1, itemId: "item-1", userId: "user-1", filePath: "/path/1.mp3", status: "downloading", progress: 0.5, bytesDownloaded: 500, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 2, itemId: "item-2", userId: "user-1", filePath: "/path/2.mp3", status: "completed", progress: 1.0, bytesDownloaded: 1000, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 3, itemId: "item-3", userId: "user-1", filePath: "/path/3.mp3", status: "completed", progress: 1.0, bytesDownloaded: 1000, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 3, activeCount: 1, queuedCount: 0, completedCount: 2, failedCount: 0, pausedCount: 0, }, }); } return Promise.reject(new Error(`Unexpected command: ${command}`)); }); await downloads.refresh("user-1"); const completed = get(completedDownloads); expect(completed.length).toBe(2); expect(completed.every((d) => d.status === "completed")).toBe(true); }); it("pendingDownloads should filter pending status", async () => { const { downloads, pendingDownloads } = await import("./downloads"); mockInvoke.mockImplementation((command: string) => { if (command === "get_downloads") { return Promise.resolve({ downloads: [ { id: 1, itemId: "item-1", userId: "user-1", filePath: "/path/1.mp3", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 10, mediaType: "audio", downloadSource: "user", }, { id: 2, itemId: "item-2", userId: "user-1", filePath: "/path/2.mp3", status: "completed", progress: 1.0, bytesDownloaded: 1000, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 3, itemId: "item-3", userId: "user-1", filePath: "/path/3.mp3", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 5, mediaType: "audio", downloadSource: "user", }, ], stats: { total: 3, activeCount: 0, queuedCount: 2, completedCount: 1, failedCount: 0, pausedCount: 0, }, }); } return Promise.reject(new Error(`Unexpected command: ${command}`)); }); await downloads.refresh("user-1"); const pending = get(pendingDownloads); expect(pending.length).toBe(2); expect(pending.every((d) => d.status === "pending")).toBe(true); }); it("failedDownloads should filter failed status", async () => { const { downloads, failedDownloads } = await import("./downloads"); mockInvoke.mockImplementation((command: string) => { if (command === "get_downloads") { return Promise.resolve({ downloads: [ { id: 1, itemId: "item-1", userId: "user-1", filePath: "/path/1.mp3", status: "failed", progress: 0.5, bytesDownloaded: 500, queuedAt: "2024-01-01T00:00:00Z", retryCount: 3, priority: 0, mediaType: "audio", downloadSource: "user", errorMessage: "Network error", }, { id: 2, itemId: "item-2", userId: "user-1", filePath: "/path/2.mp3", status: "completed", progress: 1.0, bytesDownloaded: 1000, queuedAt: "2024-01-01T00:00:00Z", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user", }, { id: 3, itemId: "item-3", userId: "user-1", filePath: "/path/3.mp3", status: "failed", progress: 0.3, bytesDownloaded: 300, queuedAt: "2024-01-01T00:00:00Z", retryCount: 2, priority: 0, mediaType: "audio", downloadSource: "user", errorMessage: "Timeout", }, ], stats: { total: 3, activeCount: 0, queuedCount: 0, completedCount: 1, failedCount: 2, pausedCount: 0, }, }); } return Promise.reject(new Error(`Unexpected command: ${command}`)); }); await downloads.refresh("user-1"); const failed = get(failedDownloads); expect(failed.length).toBe(2); expect(failed.every((d) => d.status === "failed")).toBe(true); }); }); describe("error handling", () => { it("should throw error when download_item fails", async () => { const { downloads } = await import("./downloads"); mockInvoke.mockImplementation((command: string) => { if (command === "download_item") { return Promise.reject(new Error("Backend error: failed to queue download")); } return Promise.reject(new Error(`Unexpected command: ${command}`)); }); await expect(downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3")).rejects.toThrow( "Backend error: failed to queue download", ); }); it("should throw error when refresh fails", async () => { const { downloads } = await import("./downloads"); mockInvoke.mockImplementation((command: string) => { if (command === "get_downloads") { return Promise.reject(new Error("Backend error: failed to fetch downloads")); } return Promise.reject(new Error(`Unexpected command: ${command}`)); }); await expect(downloads.refresh("user-1")).rejects.toThrow( "Backend error: failed to fetch downloads", ); }); }); });