Files
jellytau/src/lib/stores/downloads.test.ts
T
dtourolleandClaude Opus 5 b3228cb4f4 feat(downloads): estimate a transcode's size so the progress bar moves
A transcode is produced as it is sent — chunked, with no Content-Length —
and the worker reported progress 0.0 for its whole duration: an empty bar
reading "0%" while the byte count climbed for an hour. That is the case
every film whose audio must be re-encoded lands in.

The backend already fetches the item to decide the audio policy, and that
item carries what a prediction needs: the source's size (an `original`
download copies the picture, so the output is the source give or take the
audio track) and its runtime (a preset re-encodes at fixed rates, so the
size is rate × runtime — from a preset table the URL builder now shares, so
the two cannot drift). The prediction is made where the URL is resolved and
persisted as the row's file_size. The worker uses it only when the response
has no length; the server's figure always wins; an estimated bar is capped
at 99% so a low prediction never shows a finished download still running;
and the Completed event now carries the bytes actually written so the
frontend stops persisting the row's file_size as the final size.

The row renders three honest states: exact "42%", estimated "~42%" with
"X / ~Y", or — with no total at all — an indeterminate band and the bytes
so far, never "0%". The single-video button joins the series/season buttons
on the enqueue path so all three resolve, and predict, in one place.

DR-290, UT-252, UT-253, UT-254.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:18:29 +02:00

1075 lines
31 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");
});
/// A transcode's total is a prediction. The bar must carry that so it can
/// say "~", and the row's persisted size on completion must be the bytes
/// the worker counted — never the prediction.
/// TRACES: UR-071 | DR-290 | UT-254
it("carries the estimate flag through progress and persists the real size on completion", async () => {
const { downloads, initDownloadEvents } = await import("./downloads");
mockInvoke.mockResolvedValueOnce({
downloads: [
{
id: 7,
itemId: "film",
userId: "user-1",
filePath: "videos/movies/film.mp4",
fileSize: 3_000_000_000, // predicted at resolve time
status: "downloading",
progress: 0,
bytesDownloaded: 0,
queuedAt: "2024-01-01T00:00:00Z",
retryCount: 0,
priority: 0,
mediaType: "video",
downloadSource: "user",
},
],
stats: {
total: 1,
activeCount: 1,
queuedCount: 0,
completedCount: 0,
failedCount: 0,
pausedCount: 0,
},
});
await downloads.refresh("user-1");
await initDownloadEvents();
eventHandler!({
payload: {
type: "progress",
downloadId: 7,
itemId: "film",
bytesDownloaded: 1_500_000_000,
totalBytes: 3_000_000_000,
progress: 0.5,
estimated: true,
},
});
let row = get(downloads).downloads[7];
expect(row.fileSizeEstimated).toBe(true);
expect(row.progress).toBe(0.5);
mockInvoke.mockClear();
mockInvoke.mockResolvedValueOnce(undefined);
eventHandler!({
payload: {
type: "completed",
downloadId: 7,
itemId: "film",
filePath: "videos/movies/film.mp4",
bytesDownloaded: 3_120_000_000,
},
});
await new Promise((resolve) => setTimeout(resolve, 50));
row = get(downloads).downloads[7];
expect(row.status).toBe("completed");
expect(row.fileSizeEstimated).toBe(false);
expect(row.fileSize).toBe(3_120_000_000);
const persisted = mockInvoke.mock.calls.find((c) => c[0] === "mark_download_completed");
expect(persisted?.[1]).toMatchObject({ bytesDownloaded: 3_120_000_000 });
});
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",
);
});
});
});