Files
jellytau/src/lib/api/backend-integration.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

540 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { RepositoryClient } from "./repository-client";
vi.mock("@tauri-apps/api/core");
const mockInvoke = vi.mocked(invoke);
/**
* Integration tests documenting Phase 1 & 2 refactoring:
* - Sorting moved to backend (no frontend compareFn)
* - Filtering moved to backend (no frontend iteration/matching)
* - URL construction moved to backend (async Tauri invoke)
* - Search moved to backend (backend search command)
*/
describe("Backend Integration - Refactored Business Logic", () => {
let client: RepositoryClient;
beforeEach(async () => {
client = new RepositoryClient();
(invoke as any).mockResolvedValueOnce("test-handle-123");
await client.create("https://server.com", "user1", "token123", "server1");
});
describe("Sorting Delegated to Backend", () => {
it("should pass sortBy to backend instead of frontend sorting", async () => {
(invoke as any).mockResolvedValueOnce({
items: [
{ id: "item1", name: "Album A" },
{ id: "item2", name: "Album B" },
{ id: "item3", name: "Album C" },
],
totalRecordCount: 3,
});
const result = await client.getItems("library123", {
sortBy: "SortName",
sortOrder: "Ascending",
});
// Backend should have done the sorting
expect(result.items[0].name).toBe("Album A");
// Frontend doesn't have a compareFn
expect(invoke).toHaveBeenCalledWith("repository_get_items", {
handle: "test-handle-123",
parentId: "library123",
options: {
sortBy: "SortName",
sortOrder: "Ascending",
},
});
});
it("should support different sort fields via backend", async () => {
const sortFields = ["SortName", "Artist", "Album", "DatePlayed", "ProductionYear"];
for (const sortField of sortFields) {
vi.clearAllMocks();
(invoke as any).mockResolvedValueOnce("test-handle-123");
await client.create("https://server.com", "user1", "token123", "server1");
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 0,
});
await client.getItems("library123", {
sortBy: sortField,
sortOrder: "Ascending",
});
expect(invoke).toHaveBeenCalledWith(
"repository_get_items",
expect.objectContaining({
options: expect.objectContaining({
sortBy: sortField,
}),
}),
);
}
});
it("should pass sort order to backend", async () => {
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 0,
});
await client.getItems("library123", {
sortBy: "SortName",
sortOrder: "Descending",
});
expect(invoke).toHaveBeenCalledWith(
"repository_get_items",
expect.objectContaining({
options: expect.objectContaining({
sortOrder: "Descending",
}),
}),
);
});
it("should NOT include frontend compareFn (removed entirely)", async () => {
// Old code pattern:
// sortOptions: [{
// key: "title",
// label: "Title",
// compareFn: (a, b) => a.name.localeCompare(b.name) // ← REMOVED
// }]
// New code pattern:
// sortOptions: [{
// key: "SortName", // Jellyfin field name
// label: "Title"
// }]
const config = {
sortOptions: [
{ key: "SortName", label: "Title" },
{ key: "Artist", label: "Artist" },
],
};
// Verify no compareFn property exists
for (const option of config.sortOptions) {
expect((option as any).compareFn).toBeUndefined();
}
});
});
describe("Filtering Delegated to Backend", () => {
it("should pass includeItemTypes to backend", async () => {
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 0,
});
await client.getItems("library123", {
includeItemTypes: ["Audio", "MusicAlbum"],
});
expect(invoke).toHaveBeenCalledWith(
"repository_get_items",
expect.objectContaining({
options: expect.objectContaining({
includeItemTypes: ["Audio", "MusicAlbum"],
}),
}),
);
});
it("should pass genres filter to backend", async () => {
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 0,
});
await client.getItems("library123", {
genres: ["Rock", "Jazz"],
});
expect(invoke).toHaveBeenCalledWith(
"repository_get_items",
expect.objectContaining({
options: expect.objectContaining({
genres: ["Rock", "Jazz"],
}),
}),
);
});
it("should NOT include frontend filtering logic", async () => {
// Old code pattern:
// let filtered = items.filter(item => {
// return searchFields.some(field => {
// const fieldValue = item[field]?.toLowerCase() ?? "";
// return fieldValue.includes(query.toLowerCase());
// });
// }); // ← REMOVED
// New code pattern:
// Use backend search instead
(invoke as any).mockResolvedValueOnce({
items: [{ id: "item1", name: "Search Result" }],
totalRecordCount: 1,
});
const result = await client.search("query");
expect(result.items.length).toBeGreaterThan(0);
expect(invoke).toHaveBeenCalledWith(
"repository_search",
expect.objectContaining({
query: "query",
}),
);
});
it("should support pagination via backend", async () => {
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 1000,
});
await client.getItems("library123", {
startIndex: 100,
limit: 50,
});
expect(invoke).toHaveBeenCalledWith(
"repository_get_items",
expect.objectContaining({
options: expect.objectContaining({
startIndex: 100,
limit: 50,
}),
}),
);
});
});
describe("Search Delegated to Backend", () => {
it("should use backend search command instead of frontend filtering", async () => {
(invoke as any).mockResolvedValueOnce({
items: [
{ id: "item1", name: "Found Item" },
{ id: "item2", name: "Another Found Item" },
],
totalRecordCount: 2,
});
const result = await client.search("query");
expect(invoke).toHaveBeenCalledWith(
"repository_search",
expect.objectContaining({
query: "query",
}),
);
expect(result.items.length).toBe(2);
});
it("should support search with item type filters", async () => {
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 0,
});
await client.search("query", {
includeItemTypes: ["Audio"],
});
expect(invoke).toHaveBeenCalledWith(
"repository_search",
expect.objectContaining({
options: expect.objectContaining({
includeItemTypes: ["Audio"],
}),
}),
);
});
it("should NOT do client-side search filtering", async () => {
// Old code pattern:
// const query = searchInput.toLowerCase();
// const results = items.filter(item =>
// config.searchFields.some(field =>
// item[field]?.toLowerCase()?.includes(query)
// )
// ); // ← REMOVED
// New code pattern:
// Call backend search directly
(invoke as any).mockResolvedValueOnce({
items: [{ id: "item1" }],
totalRecordCount: 1,
});
const result = await client.search("search term");
// Backend did the filtering
expect(result.items).toBeDefined();
expect(invoke).toHaveBeenCalledWith("repository_search", expect.any(Object));
});
});
describe("URL Construction Delegated to Backend", () => {
it("should get image URLs from backend (not construct in frontend)", async () => {
const backendUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
(invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getImageUrl("item123", "Primary");
// Backend constructed and returned the URL
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_image_url",
expect.objectContaining({
itemId: "item123",
imageType: "Primary",
}),
);
});
it("should NOT construct image URLs in frontend", async () => {
// Old code pattern:
// return `${serverUrl}/Items/${itemId}/Images/${imageType}?api_key=${token}&maxWidth=${options.maxWidth}`;
// ← REMOVED - NEVER construct URLs in frontend
(invoke as any).mockResolvedValueOnce("https://server.com/image");
const url = await client.getImageUrl("item123", "Primary", { maxWidth: 300 });
// URL came from backend, not constructed in frontend
expect(typeof url).toBe("string");
expect(url).toContain("http");
});
it("should get video stream URLs from backend", async () => {
const backendUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
(invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getVideoStreamUrl("item123");
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", expect.any(Object));
});
it("should get subtitle URLs from backend", async () => {
const backendUrl =
"https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
(invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getSubtitleUrl("item123", "source456", 0);
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", expect.any(Object));
});
it("should get video download URLs from backend", async () => {
const backendUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1280&api_key=token";
(invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getVideoDownloadUrl("item123", "medium");
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", expect.any(Object));
});
it("should never expose access token in frontend code", async () => {
// The access token is NEVER used in frontend URL construction
// It's only stored in backend for secure URL generation
// Frontend code NEVER has direct access to use the token
const client2 = new RepositoryClient();
// client2._accessToken is private and should never be accessed or used
// All token usage is in backend via Tauri commands
expect(invoke).toBeDefined();
});
});
describe("Component Config Simplification", () => {
it("should have simplified MediaListConfig (no searchFields)", () => {
// Old type:
// interface MediaListConfig {
// searchFields: string[]; // ← REMOVED
// compareFn?: (a, b) => number; // ← REMOVED
// }
// New type:
const config = {
itemType: "Audio",
title: "Tracks",
sortOptions: [
{ key: "SortName", label: "Title" },
// No compareFn
],
// No searchFields
};
// Verify no searchFields
expect((config as any).searchFields).toBeUndefined();
});
it("should use Jellyfin field names in sort options", () => {
// Old:
// { key: "title", label: "Title", compareFn: ... }
// New:
// { key: "SortName", label: "Title" }
const sortOptions = [
{ key: "SortName", label: "A-Z" },
{ key: "Artist", label: "Artist" },
{ key: "Album", label: "Album" },
{ key: "DatePlayed", label: "Recent" },
];
for (const option of sortOptions) {
// Should be Jellyfin field names
expect(typeof option.key).toBe("string");
expect(option.key).toMatch(/^[A-Z]/); // Jellyfin fields start with capital
}
});
});
describe("Debounced Search Implementation", () => {
it("should debounce search without frontend filtering", async () => {
vi.useFakeTimers();
const mockSearch = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
(invoke as any).mockImplementation((cmd: string, args: any) => {
if (cmd === "repository_search") {
return mockSearch(args.query);
}
return Promise.resolve({ items: [], totalRecordCount: 0 });
});
// Simulate rapid search queries
await client.search("t");
vi.advanceTimersByTime(100);
await client.search("te");
vi.advanceTimersByTime(100);
await client.search("test");
vi.advanceTimersByTime(300);
// All calls go to backend (debouncing happens in component via $effect)
expect(invoke).toHaveBeenCalled();
vi.useRealTimers();
});
});
describe("End-to-End Data Flow", () => {
it("should support complete flow: load → sort → display", async () => {
(invoke as any).mockResolvedValueOnce({
items: [
{ id: "id1", name: "Album A" },
{ id: "id2", name: "Album B" },
],
totalRecordCount: 2,
});
// Frontend requests items with sort
const result = await client.getItems("library123", {
sortBy: "SortName",
sortOrder: "Ascending",
});
// Backend returned pre-sorted items
expect(result.items[0].name).toBe("Album A");
expect(result.items[1].name).toBe("Album B");
// Frontend just displays them
// No compareFn, no local sorting
});
it("should support complete flow: search → load images → display", async () => {
// 1. Frontend calls backend search
(invoke as any).mockResolvedValueOnce({
items: [{ id: "item1", name: "Result", primaryImageTag: "tag1" }],
totalRecordCount: 1,
});
const searchResult = await client.search("query");
expect(searchResult.items.length).toBe(1);
// 2. Frontend loads image URL from backend
(invoke as any).mockResolvedValueOnce("https://server.com/image.jpg");
const imageUrl = await client.getImageUrl("item1", "Primary");
expect(imageUrl).toContain("http");
// 3. Frontend displays search results with images
// No client-side filtering, sorting, or URL construction
});
});
describe("Performance Characteristics", () => {
it("should reduce memory usage by not storing frontend sorting state", async () => {
// Old: Frontend stores items + sorting state + filtered results
// Old: Multiple copies of data (original, filtered, sorted)
// New: Backend returns already-sorted data
// New: Frontend just stores the result
(invoke as any).mockResolvedValueOnce({
items: Array.from({ length: 10000 }, (_, i) => ({
id: `id${i}`,
name: `Item ${i}`,
})),
totalRecordCount: 10000,
});
const result = await client.getItems("library123", {
sortBy: "SortName",
limit: 10000,
});
// Backend handled sorting
expect(result.items.length).toBe(10000);
// Frontend just stores the result array
});
it("should reduce CPU usage by avoiding client-side operations", async () => {
// Old pattern required:
// - Parsing all items into memory
// - Iterating to apply filters
// - Sorting algorithm (O(n log n) comparisons)
// - Updating multiple state variables
// New pattern:
(invoke as any).mockResolvedValueOnce({
items: [], // Backend already filtered/sorted
totalRecordCount: 0,
});
// Frontend just awaits backend result
const result = await client.getItems("library123", {
sortBy: "SortName",
includeItemTypes: ["Audio"],
});
// No client-side work
expect(result).toBeDefined();
});
});
});