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); describe("RepositoryClient", () => { let client: RepositoryClient; beforeEach(() => { client = new RepositoryClient(); vi.clearAllMocks(); }); afterEach(() => { vi.resetAllMocks(); }); describe("Initialization", () => { it("should initialize with no handle", () => { expect(() => client.getHandle()).toThrow("Repository not initialized"); }); it("should create repository with invoke command", async () => { const mockHandle = "test-handle-123"; (invoke as any).mockResolvedValueOnce(mockHandle); const handle = await client.create("https://server.com", "user1", "token123", "server1"); expect(handle).toBe(mockHandle); expect(invoke).toHaveBeenCalledWith("repository_create", { serverUrl: "https://server.com", userId: "user1", accessToken: "token123", serverId: "server1", }); }); it("should store handle after creation", async () => { const mockHandle = "test-handle-456"; (invoke as any).mockResolvedValueOnce(mockHandle); await client.create("https://server.com", "user1", "token123", "server1"); expect(client.getHandle()).toBe(mockHandle); }); it("should destroy repository and clear handle", async () => { const mockHandle = "test-handle-789"; (invoke as any).mockResolvedValueOnce(mockHandle); await client.create("https://server.com", "user1", "token123", "server1"); (invoke as any).mockResolvedValueOnce(undefined); await client.destroy(); expect(() => client.getHandle()).toThrow("Repository not initialized"); expect(invoke).toHaveBeenCalledWith("repository_destroy", { handle: mockHandle }); }); }); describe("Image URL Methods", () => { beforeEach(async () => { (invoke as any).mockResolvedValueOnce("test-handle-123"); await client.create("https://server.com", "user1", "token123", "server1"); }); it("should get image URL from backend", async () => { const mockUrl = "https://server.com/Items/item123/Images/Primary?maxWidth=300&api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); const imageUrl = await client.getImageUrl("item123", "Primary", { maxWidth: 300 }); expect(imageUrl).toBe(mockUrl); expect(invoke).toHaveBeenCalledWith("repository_get_image_url", { handle: "test-handle-123", itemId: "item123", imageType: "Primary", options: { maxWidth: 300 }, }); }); it("should use default image type if not provided", async () => { const mockUrl = "https://server.com/Items/item123/Images/Primary?api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); await client.getImageUrl("item123"); expect(invoke).toHaveBeenCalledWith("repository_get_image_url", { handle: "test-handle-123", itemId: "item123", imageType: "Primary", options: null, }); }); it("should pass multiple image options to backend", async () => { const mockUrl = "https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); const options = { maxWidth: 1920, maxHeight: 1080, quality: 90, tag: "abc123", }; await client.getImageUrl("item123", "Backdrop", options); expect(invoke).toHaveBeenCalledWith("repository_get_image_url", { handle: "test-handle-123", itemId: "item123", imageType: "Backdrop", options, }); }); it("should handle different image types", async () => { const mockUrl = "https://server.com/Items/item123/Images/Logo?api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); await client.getImageUrl("item123", "Logo"); expect(invoke).toHaveBeenCalledWith("repository_get_image_url", { handle: expect.any(String), itemId: "item123", imageType: "Logo", options: null, }); }); it("should throw error if not initialized before getImageUrl", async () => { const newClient = new RepositoryClient(); await expect(newClient.getImageUrl("item123")).rejects.toThrow( "Repository not initialized" ); }); }); describe("Subtitle URL Methods", () => { beforeEach(async () => { (invoke as any).mockResolvedValueOnce("test-handle-123"); await client.create("https://server.com", "user1", "token123", "server1"); }); it("should get subtitle URL from backend", async () => { const mockUrl = "https://server.com/Videos/item123/Subtitles/1/subtitles.vtt?api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); const subtitleUrl = await client.getSubtitleUrl("item123", "source456", 0); expect(subtitleUrl).toBe(mockUrl); expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", { handle: "test-handle-123", itemId: "item123", mediaSourceId: "source456", streamIndex: 0, format: "vtt", }); }); it("should use default format if not provided", async () => { const mockUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); await client.getSubtitleUrl("item123", "source456", 0); expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", { handle: expect.any(String), itemId: "item123", mediaSourceId: "source456", streamIndex: 0, format: "vtt", }); }); it("should support custom subtitle formats", async () => { const mockUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.srt?api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); await client.getSubtitleUrl("item123", "source456", 1, "srt"); expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", { handle: expect.any(String), itemId: "item123", mediaSourceId: "source456", streamIndex: 1, format: "srt", }); }); }); describe("Video Download URL Methods", () => { beforeEach(async () => { (invoke as any).mockResolvedValueOnce("test-handle-123"); await client.create("https://server.com", "user1", "token123", "server1"); }); it("should get video download URL from backend", async () => { const mockUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1920&api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); const downloadUrl = await client.getVideoDownloadUrl("item123", "high"); expect(downloadUrl).toBe(mockUrl); expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", { handle: "test-handle-123", itemId: "item123", quality: "high", mediaSourceId: null, }); }); it("should use original quality by default", async () => { const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); await client.getVideoDownloadUrl("item123"); expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", { handle: expect.any(String), itemId: "item123", quality: "original", mediaSourceId: null, }); }); it("should support quality presets", async () => { const qualities = ["original", "high", "medium", "low"]; for (const quality of qualities) { vi.clearAllMocks(); (invoke as any).mockResolvedValueOnce("test-handle-123"); await client.create("https://server.com", "user1", "token123", "server1"); (invoke as any).mockResolvedValueOnce(`https://server.com/stream.mp4?quality=${quality}`); await client.getVideoDownloadUrl("item123", quality as any); expect(invoke).toHaveBeenCalledWith( "repository_get_video_download_url", expect.objectContaining({ quality, }) ); } }); it("should support optional media source ID", async () => { const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); await client.getVideoDownloadUrl("item123", "medium", "source789"); expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", { handle: expect.any(String), itemId: "item123", quality: "medium", mediaSourceId: "source789", }); }); }); describe("Library Methods", () => { beforeEach(async () => { (invoke as any).mockResolvedValueOnce("test-handle-123"); await client.create("https://server.com", "user1", "token123", "server1"); }); it("should get libraries from backend", async () => { const mockLibraries = [ { id: "lib1", name: "Music", collectionType: "music" }, { id: "lib2", name: "Movies", collectionType: "movies" }, ]; (invoke as any).mockResolvedValueOnce(mockLibraries); const libraries = await client.getLibraries(); expect(libraries).toEqual(mockLibraries); expect(invoke).toHaveBeenCalledWith("repository_get_libraries", { handle: "test-handle-123", }); }); it("should get items with sorting parameters", async () => { const mockResult = { items: [ { id: "item1", name: "Track 1", type: "Audio" }, { id: "item2", name: "Track 2", type: "Audio" }, ], totalRecordCount: 2, }; (invoke as any).mockResolvedValueOnce(mockResult); const result = await client.getItems("library123", { sortBy: "SortName", sortOrder: "Ascending", limit: 50, }); expect(result).toEqual(mockResult); expect(invoke).toHaveBeenCalledWith("repository_get_items", { handle: "test-handle-123", parentId: "library123", options: { sortBy: "SortName", sortOrder: "Ascending", limit: 50, }, }); }); it("should search with backend search command", async () => { const mockResult = { items: [ { id: "item1", name: "Search Result 1", type: "Audio" }, ], totalRecordCount: 1, }; (invoke as any).mockResolvedValueOnce(mockResult); const result = await client.search("query", { includeItemTypes: ["Audio"], limit: 100, }); expect(result).toEqual(mockResult); expect(invoke).toHaveBeenCalledWith("repository_search", { handle: "test-handle-123", query: "query", options: { includeItemTypes: ["Audio"], limit: 100, }, requestId: 0, }); }); // Downloaded-only browse path (UR-055 | DR-082) — verifies command names and // camelCase params per the Tauri v2 rule (CLAUDE.md). it("should get downloaded libraries from backend", async () => { const mockLibraries = [{ id: "lib1", name: "Music", collectionType: "music" }]; (invoke as any).mockResolvedValueOnce(mockLibraries); const libraries = await client.getDownloadedLibraries(); expect(libraries).toEqual(mockLibraries); expect(invoke).toHaveBeenCalledWith("repository_get_downloaded_libraries", { handle: "test-handle-123", }); }); it("should get downloaded items with camelCase params", async () => { const mockResult = { items: [{ id: "t1", name: "Track", type: "Audio" }], totalRecordCount: 1 }; (invoke as any).mockResolvedValueOnce(mockResult); const result = await client.getDownloadedItems("album1", { limit: 50 }); expect(result).toEqual(mockResult); expect(invoke).toHaveBeenCalledWith("repository_get_downloaded_items", { handle: "test-handle-123", parentId: "album1", options: { limit: 50 }, }); }); it("should get download disk usage from backend", async () => { const mockUsage = { sizes: { t1: 1000 }, partialContainers: {}, deviceTotalBytes: 1000, itemCount: 1 }; (invoke as any).mockResolvedValueOnce(mockUsage); const usage = await client.getDownloadDiskUsage(); expect(usage).toEqual(mockUsage); expect(invoke).toHaveBeenCalledWith("repository_get_download_disk_usage", { handle: "test-handle-123", }); }); }); describe("Playback Methods", () => { beforeEach(async () => { (invoke as any).mockResolvedValueOnce("test-handle-123"); await client.create("https://server.com", "user1", "token123", "server1"); }); it("should get audio stream URL", async () => { const mockUrl = "https://server.com/Audio/item123/stream.mp3?api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); const url = await client.getAudioStreamUrl("item123"); expect(url).toBe(mockUrl); expect(invoke).toHaveBeenCalledWith("repository_get_audio_stream_url", { handle: "test-handle-123", itemId: "item123", }); }); it("should get video stream URL", async () => { const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); const url = await client.getVideoStreamUrl("item123"); expect(url).toBe(mockUrl); expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", { handle: "test-handle-123", itemId: "item123", mediaSourceId: null, audioStreamIndex: null, }); }); /** * There is no start-position argument: a position on the HLS playlist makes * the server reject every segment behind it with 400, so resume and seek are * performed by seeking the player after load (DR-181). */ it("should get video stream URL with options", async () => { const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token"; (invoke as any).mockResolvedValueOnce(mockUrl); const url = await client.getVideoStreamUrl("item123", "source456", 0); expect(url).toBe(mockUrl); expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", { handle: "test-handle-123", itemId: "item123", mediaSourceId: "source456", audioStreamIndex: 0, }); }); it("should get audio-only stream URL for a video item (camelCase params)", async () => { // TRACES: UR-040 | JA-032 | UT-061 const mockUrl = "https://server.com/Audio/item123/universal?AudioStreamIndex=2"; (invoke as any).mockResolvedValueOnce(mockUrl); const url = await client.getAudioOnlyStreamUrlForVideo("item123", "source456", 193, 2); expect(url).toBe(mockUrl); expect(invoke).toHaveBeenCalledWith("repository_get_audio_only_stream_url_for_video", { handle: "test-handle-123", itemId: "item123", mediaSourceId: "source456", startTimeSeconds: 193, audioStreamIndex: 2, }); }); it("should default optional params to null for audio-only stream URL", async () => { // TRACES: UR-040 | JA-032 | UT-061 (invoke as any).mockResolvedValueOnce("https://server.com/Audio/item123/universal"); await client.getAudioOnlyStreamUrlForVideo("item123"); expect(invoke).toHaveBeenCalledWith("repository_get_audio_only_stream_url_for_video", { handle: "test-handle-123", itemId: "item123", mediaSourceId: null, startTimeSeconds: null, audioStreamIndex: null, }); }); it("should report playback progress", async () => { (invoke as any).mockResolvedValueOnce(undefined); await client.reportPlaybackProgress("item123", 5000000); expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", { handle: "test-handle-123", itemId: "item123", positionMs: 5000000, }); }); }); describe("Playlist Methods", () => { beforeEach(async () => { (invoke as any).mockResolvedValueOnce("test-handle-123"); await client.create("https://server.com", "user1", "token123", "server1"); }); it("should create a playlist", async () => { const mockResult = { id: "playlist-001" }; (invoke as any).mockResolvedValueOnce(mockResult); const result = await client.createPlaylist("My Playlist", ["track1", "track2"]); expect(result).toEqual(mockResult); expect(invoke).toHaveBeenCalledWith("playlist_create", { handle: "test-handle-123", name: "My Playlist", itemIds: ["track1", "track2"], }); }); it("should create a playlist without initial items", async () => { const mockResult = { id: "playlist-002" }; (invoke as any).mockResolvedValueOnce(mockResult); await client.createPlaylist("Empty Playlist"); expect(invoke).toHaveBeenCalledWith("playlist_create", { handle: "test-handle-123", name: "Empty Playlist", itemIds: null, }); }); it("should delete a playlist", async () => { (invoke as any).mockResolvedValueOnce(undefined); await client.deletePlaylist("playlist-001"); expect(invoke).toHaveBeenCalledWith("playlist_delete", { handle: "test-handle-123", playlistId: "playlist-001", }); }); it("should rename a playlist", async () => { (invoke as any).mockResolvedValueOnce(undefined); await client.renamePlaylist("playlist-001", "New Name"); expect(invoke).toHaveBeenCalledWith("playlist_rename", { handle: "test-handle-123", playlistId: "playlist-001", name: "New Name", }); }); it("should get playlist items", async () => { const mockItems = [ { playlistItemId: "entry1", id: "track1", name: "Track 1", type: "Audio" }, { playlistItemId: "entry2", id: "track2", name: "Track 2", type: "Audio" }, ]; (invoke as any).mockResolvedValueOnce(mockItems); const items = await client.getPlaylistItems("playlist-001"); expect(items).toEqual(mockItems); expect(invoke).toHaveBeenCalledWith("playlist_get_items", { handle: "test-handle-123", playlistId: "playlist-001", }); }); it("should add items to a playlist", async () => { (invoke as any).mockResolvedValueOnce(undefined); await client.addToPlaylist("playlist-001", ["track3", "track4"]); expect(invoke).toHaveBeenCalledWith("playlist_add_items", { handle: "test-handle-123", playlistId: "playlist-001", itemIds: ["track3", "track4"], }); }); it("should remove items from a playlist using entry IDs", async () => { (invoke as any).mockResolvedValueOnce(undefined); await client.removeFromPlaylist("playlist-001", ["entry1", "entry2"]); expect(invoke).toHaveBeenCalledWith("playlist_remove_items", { handle: "test-handle-123", playlistId: "playlist-001", entryIds: ["entry1", "entry2"], }); }); it("should move a playlist item", async () => { (invoke as any).mockResolvedValueOnce(undefined); await client.movePlaylistItem("playlist-001", "track1", 3); expect(invoke).toHaveBeenCalledWith("playlist_move_item", { handle: "test-handle-123", playlistId: "playlist-001", itemId: "track1", newIndex: 3, }); }); it("should throw error if not initialized before playlist operations", async () => { const newClient = new RepositoryClient(); await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow("Repository not initialized"); await expect(newClient.createPlaylist("test")).rejects.toThrow("Repository not initialized"); await expect(newClient.deletePlaylist("pl-1")).rejects.toThrow("Repository not initialized"); }); }); describe("Error Handling", () => { it("should throw error if invoke fails", async () => { (invoke as any).mockRejectedValueOnce(new Error("Network error")); await expect(client.create("https://server.com", "user1", "token", "server1")).rejects.toThrow( "Network error" ); }); it("should handle missing optional parameters", async () => { (invoke as any).mockResolvedValueOnce("test-handle-123"); await client.create("https://server.com", "user1", "token123", "server1"); (invoke as any).mockResolvedValueOnce(""); await client.getImageUrl("item123"); expect(invoke).toHaveBeenCalledWith( "repository_get_image_url", expect.objectContaining({ options: null, }) ); }); }); });