// 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("album-1", "user-1", "/base/path"); expect(mockInvoke).toHaveBeenCalledWith("download_album", { 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 }); 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"); }); }); });