/** * Integration test: Tauri command invocations * * This test validates that invoke parameters use the correct naming convention. * * IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts * snake_case Rust parameter names to camelCase for the frontend. * All top-level parameters must use camelCase. * * RUN THIS BEFORE DEPLOYING: * ```bash * npm test -- tauriIntegration.test.ts * ``` */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; /** * Mock Tauri invoke to capture actual calls from production code */ interface InvokeCall { command: string; args: Record; timestamp: number; } let invokeHistory: InvokeCall[] = []; let invokeErrors: Map = new Map(); const mockInvoke = vi.fn(async (command: string, args?: Record) => { const callArgs = args || {}; invokeHistory.push({ command, args: callArgs, timestamp: Date.now(), }); // Check if this command should error const error = invokeErrors.get(command); if (error) { throw error; } // Simulate Tauri command success return { success: true }; }); /** * Expected command signatures - the source of truth * All parameter names are camelCase (Tauri v2 auto-converts from Rust snake_case) */ const COMMAND_SPECS: Record< string, { requiredParams: string[]; description: string; } > = { player_play_tracks: { requiredParams: ["repositoryHandle", "request"], description: "Play a set of tracks", }, playback_mode_set: { requiredParams: ["mode"], description: "Set playback mode", }, playback_mode_transfer_to_local: { requiredParams: ["currentItemId", "positionTicks"], description: "Transfer playback to local device", }, remote_send_command: { requiredParams: ["sessionId", "command"], description: "Send command to remote session", }, remote_play_on_session: { requiredParams: ["sessionId", "itemIds", "startIndex"], description: "Play items on remote session", }, remote_session_seek: { requiredParams: ["sessionId", "positionTicks"], description: "Seek on remote session", }, pin_item: { requiredParams: ["itemId"], description: "Pin an item for download", }, unpin_item: { requiredParams: ["itemId"], description: "Unpin an item", }, player_add_track_by_id: { requiredParams: ["repositoryHandle", "request"], description: "Add track to queue by ID", }, player_add_tracks_by_ids: { requiredParams: ["repositoryHandle", "request"], description: "Add tracks to queue by IDs", }, player_play_album_track: { requiredParams: ["repositoryHandle", "request"], description: "Play track from album", }, }; /** * Validate an invoke call against the spec */ function validateInvokeCall(call: InvokeCall): { valid: boolean; errors: string[] } { const errors: string[] = []; const spec = COMMAND_SPECS[call.command]; if (!spec) { errors.push(`Unknown command: ${call.command}`); return { valid: errors.length === 0, errors }; } // Check all required parameters are present for (const paramName of spec.requiredParams) { if (!(paramName in call.args)) { errors.push( `Missing required parameter "${paramName}" for ${call.command}` ); } } // Check for snake_case violations (should be camelCase) const snakeCaseViolations: Record = { repository_handle: "repositoryHandle", current_item_id: "currentItemId", position_ticks: "positionTicks", session_id: "sessionId", item_ids: "itemIds", start_index: "startIndex", item_id: "itemId", }; for (const [snakeCase, camelCase] of Object.entries(snakeCaseViolations)) { if (snakeCase in call.args) { errors.push( `Found snake_case "${snakeCase}" instead of "${camelCase}" for ${call.command}` ); } } return { valid: errors.length === 0, errors, }; } describe("Tauri Integration - Command Invocations", () => { beforeEach(() => { invokeHistory = []; invokeErrors.clear(); // Mock Tauri invoke in the context where it will be imported vi.doMock("@tauri-apps/api/core", () => ({ invoke: mockInvoke, })); }); afterEach(() => { vi.clearAllMocks(); invokeHistory = []; invokeErrors.clear(); }); describe("playbackMode store", () => { it("should invoke player_play_tracks with correct parameter names", async () => { await mockInvoke("player_play_tracks", { repositoryHandle: "test-repo", request: { trackIds: ["id1", "id2"], startIndex: 0, shuffle: false, context: { type: "search", searchQuery: "test", }, }, }); const lastCall = invokeHistory[invokeHistory.length - 1]; const validation = validateInvokeCall(lastCall); if (!validation.valid) { throw new Error( `player_play_tracks invocation failed validation:\n${validation.errors.join("\n")}` ); } // Verify the actual parameters expect(lastCall.command).toBe("player_play_tracks"); expect(lastCall.args).toHaveProperty("repositoryHandle"); expect(lastCall.args.repositoryHandle).toBe("test-repo"); expect(lastCall.args.request).toHaveProperty("trackIds"); expect(lastCall.args.request).toHaveProperty("context"); expect(lastCall.args.request.context).toHaveProperty("searchQuery"); }); it("should NOT use repository_handle - must be repositoryHandle", async () => { // This test documents the WRONG way const wrongCall: InvokeCall = { command: "player_play_tracks", args: { repository_handle: "test-repo", // ❌ WRONG - snake_case request: {}, }, timestamp: Date.now(), }; const validation = validateInvokeCall(wrongCall); // This should fail because the parameter name is wrong expect(validation.valid).toBe(false); expect(validation.errors.some((e) => e.includes("repository_handle"))).toBe(true); }); it("should invoke playback_mode_transfer_to_local with correct parameters", async () => { await mockInvoke("playback_mode_transfer_to_local", { currentItemId: "item-123", positionTicks: 50000, }); const lastCall = invokeHistory[invokeHistory.length - 1]; const validation = validateInvokeCall(lastCall); expect(validation.valid).toBe(true); expect(lastCall.args).toHaveProperty("currentItemId"); expect(lastCall.args).toHaveProperty("positionTicks"); }); }); describe("sessions store", () => { it("should invoke remote_send_command with sessionId parameter", async () => { await mockInvoke("remote_send_command", { sessionId: "session-123", // ✓ Correct command: "PlayPause", }); const lastCall = invokeHistory[invokeHistory.length - 1]; const validation = validateInvokeCall(lastCall); expect(validation.valid).toBe(true); expect(lastCall.args).toHaveProperty("sessionId"); expect(lastCall.args).not.toHaveProperty("session_id"); }); it("should NOT use session_id - must be sessionId", async () => { const wrongCall: InvokeCall = { command: "remote_send_command", args: { session_id: "session-123", // ❌ WRONG command: "PlayPause", }, timestamp: Date.now(), }; const validation = validateInvokeCall(wrongCall); expect(validation.valid).toBe(false); }); it("should invoke remote_play_on_session with correct parameters", async () => { await mockInvoke("remote_play_on_session", { sessionId: "session-123", itemIds: ["id1", "id2"], startIndex: 0, }); const lastCall = invokeHistory[invokeHistory.length - 1]; const validation = validateInvokeCall(lastCall); expect(validation.valid).toBe(true); expect(lastCall.args).toHaveProperty("sessionId"); expect(lastCall.args).toHaveProperty("itemIds"); expect(lastCall.args).toHaveProperty("startIndex"); }); }); describe("downloads store", () => { it("should invoke pin_item with itemId parameter", async () => { await mockInvoke("pin_item", { itemId: "item-123", // ✓ Correct }); const lastCall = invokeHistory[invokeHistory.length - 1]; const validation = validateInvokeCall(lastCall); expect(validation.valid).toBe(true); expect(lastCall.args).toHaveProperty("itemId"); }); it("should NOT use item_id - must be itemId", async () => { const wrongCall: InvokeCall = { command: "pin_item", args: { item_id: "item-123", // ❌ WRONG }, timestamp: Date.now(), }; const validation = validateInvokeCall(wrongCall); expect(validation.valid).toBe(false); }); }); describe("queue store", () => { it("should invoke player_add_track_by_id with repositoryHandle", async () => { await mockInvoke("player_add_track_by_id", { repositoryHandle: "repo-123", request: { trackId: "track-123", position: 0, }, }); const lastCall = invokeHistory[invokeHistory.length - 1]; const validation = validateInvokeCall(lastCall); expect(validation.valid).toBe(true); expect(lastCall.args).toHaveProperty("repositoryHandle"); }); it("should invoke player_play_album_track with correct parameters", async () => { await mockInvoke("player_play_album_track", { repositoryHandle: "repo-123", request: { albumId: "album-123", albumName: "Test Album", trackId: "track-123", shuffle: false, }, }); const lastCall = invokeHistory[invokeHistory.length - 1]; const validation = validateInvokeCall(lastCall); expect(validation.valid).toBe(true); expect(lastCall.args.request).toHaveProperty("albumId"); expect(lastCall.args.request).toHaveProperty("albumName"); expect(lastCall.args.request).toHaveProperty("trackId"); }); }); describe("Comprehensive validation", () => { it("should validate all recorded invoke calls", async () => { // Simulate multiple invoke calls from different parts of the app await mockInvoke("player_play_tracks", { repositoryHandle: "repo1", request: { trackIds: ["1"], startIndex: 0, shuffle: false, context: { type: "search", searchQuery: "" } }, }); await mockInvoke("pin_item", { itemId: "item1", }); await mockInvoke("remote_send_command", { sessionId: "session1", command: "PlayPause", }); // Validate ALL calls const validationResults = invokeHistory.map((call) => ({ command: call.command, validation: validateInvokeCall(call), })); const failures = validationResults.filter((r) => !r.validation.valid); if (failures.length > 0) { const errorMessages = failures .map( (f) => `${f.command}: ${f.validation.errors.join("; ")}` ) .join("\n"); throw new Error( `Found ${failures.length} invalid invoke calls:\n${errorMessages}` ); } expect(failures).toHaveLength(0); expect(invokeHistory).toHaveLength(3); }); }); });