An album download put a handful of its tracks on the device while the button reported the album as downloaded. Two independent gaps, one shared cause. - `download_album` read its track list from `items WHERE album_id = ?` — the local catalog cache. Jellyfin does not return `AlbumId` on every listing endpoint, so tracks cached from one of those sit in `items` with a NULL `album_id` and are invisible to that query. On the reported database three whole albums (18, 12 and 9 tracks) had it NULL on every track; a partially linked album queued only the linked subset. - The frontend then resolved one stream URL per track from its own list and paired it with the returned row ids by position. The ids came back in the backend's `index_number` order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started. On Android that loop also stopped wherever the webview was suspended. - `album_id` is what `OfflineRepository::get_items` joins a track to its album on, so a track that did download stayed invisible under its album offline — the same missing link seen from the other side. The operation now belongs to Rust end to end: - `HybridRepository::get_album_tracks` asks the server what the album contains. Cache-first `get_items` is right for browsing and wrong for deciding what to download; it errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow. - `queue_album_tracks` writes the album link onto every track it queues, and creates an `items` row for tracks the cache has never seen. - Stream URLs resolve here, through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Only the album id crosses the IPC boundary. - `album_file_names` gives each track its own file. A title repeated inside one album (deluxe edition, two discs) mapped to one path, so those downloads overwrote each other. Re-tapping download on a broken album heals it: missing tracks are queued and the tracks already on disk get their link. `download_series`/`download_season` still derive their episode lists from the cache the same way and want the same treatment. DR-173, UT-170..172. Rust 673 tests, frontend 975 tests, svelte-check and check:boundary clean. Note: this tree is shared with a concurrent session. Only the files above are committed; docs/traceability.md is left to be regenerated once that work lands.
944 lines
28 KiB
TypeScript
944 lines
28 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");
|
|
});
|
|
});
|
|
});
|