Files
jellytau/src/lib/stores/downloads.test.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

1002 lines
29 KiB
TypeScript

// 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",
);
});
});
});