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(); }); }); });