From d7d980b6264afdf886b8357811912f606bc85210 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 15 Aug 2026 08:50:07 +0800 Subject: [PATCH 1/2] Give devices a stable identity instead of keying on address Addresses were the device identity: DHCP moved them, mDNS gave one box two names, and credentials keyed by address meant a device inheriting a recycled IP picked up the previous device's auth token. Devices now live in a registry keyed by an opaque recordId, with addresses demoted to endpoints hanging off the record. Credentials are stored under record:, media and library caches namespace on the record key, and the device detail route takes a recordId rather than an address. Existing installs migrate once on hydrate: deviceHistory and deviceAddress become records, each carrying legacyCredentialKey so the old credential stays readable until the first authenticated connect re-keys it, after which both legacy Preferences entries are deleted. Migration is covered end to end, including the IPv6 normalisation change and corrupt-history recovery. Claude-Session: https://claude.ai/code/session_01MevSjtLDnHofR1Eub9vKGN --- .../integration/connection-flow.test.tsx | 33 +- src/__tests__/integration/home-page.test.tsx | 6 +- .../integration/index-route.test.tsx | 7 +- .../integration/network-scan-modal.test.tsx | 29 +- src/__tests__/unit/App.firebase-auth.test.tsx | 1 - src/__tests__/unit/App.integration.test.tsx | 1 - .../components/ConnectionProvider.test.tsx | 328 +++--- .../ConnectionStatusDisplay.test.tsx | 16 +- .../components/DeviceConnectionCard.test.tsx | 9 +- .../components/MediaDatabaseCard.test.tsx | 6 +- .../components/MediaDetailsModal.test.tsx | 5 +- .../unit/components/PageFrame.test.tsx | 6 +- .../unit/components/PairingModal.test.tsx | 117 +-- .../components/SimpleSystemSelect.test.tsx | 11 +- .../unit/components/SystemSelector.test.tsx | 17 +- .../unit/components/TagSelector.test.tsx | 13 +- .../components/home/ConnectionStatus.test.tsx | 21 +- .../library/FavoriteButton.test.tsx | 11 +- .../library/LibraryArtwork.test.tsx | 4 +- .../library/LibraryBrowseList.test.tsx | 4 +- .../library/LibraryMediaDetailsModal.test.tsx | 3 +- src/__tests__/unit/coreApi.internals.test.ts | 76 +- .../unit/coreApi.write-operations.test.ts | 118 +-- .../unit/hooks/useActiveDeviceKey.test.tsx | 95 ++ .../unit/hooks/useDeviceLinking.test.ts | 1 - .../unit/hooks/useLibraryBrowse.test.tsx | 8 +- .../unit/hooks/useSelectDevice.test.tsx | 426 ++++---- .../unit/lib/coreApi.playtime.test.ts | 92 +- src/__tests__/unit/lib/coreApi.test.ts | 130 --- src/__tests__/unit/lib/coreApi.url.test.ts | 231 ----- .../unit/lib/coreApi.validateAddress.test.ts | 77 ++ .../unit/lib/crypto/credentials.test.ts | 132 ++- .../unit/lib/devices/deviceRegistry.test.ts | 730 ++++++++++++++ .../unit/lib/devices/endpoint.test.ts | 172 ++++ .../unit/lib/libraryImageCache.test.ts | 2 +- src/__tests__/unit/lib/libraryImages.test.ts | 4 +- src/__tests__/unit/lib/storage.test.ts | 103 -- src/__tests__/unit/lib/store.test.ts | 157 --- .../unit/routes/library.favorites.test.tsx | 5 +- .../unit/routes/library.index.test.tsx | 8 +- .../unit/routes/library.search.test.tsx | 5 +- .../unit/routes/library.system.test.tsx | 5 +- .../routes/settings.devices-detail.test.tsx | 231 ++--- .../unit/routes/settings.devices.test.tsx | 189 ++-- .../unit/routes/settings.index.test.tsx | 12 +- src/components/ConnectionProvider.tsx | 429 ++++---- src/components/ConnectionStatusDisplay.tsx | 7 +- src/components/DeviceConnectionCard.tsx | 25 +- src/components/MediaDatabaseCard.tsx | 7 +- src/components/MediaDetailsModal.tsx | 8 +- src/components/MediaScrapeCard.tsx | 7 +- src/components/NetworkScanModal.tsx | 60 +- src/components/PairingModal.tsx | 31 +- src/components/SimpleSystemSelect.tsx | 8 +- src/components/SystemSelector.tsx | 8 +- src/components/TagSelector.tsx | 8 +- src/components/home/ConnectionStatus.tsx | 7 +- src/components/library/FavoriteButton.tsx | 8 +- src/components/library/LibraryArtwork.tsx | 8 +- src/components/library/LibraryBrowseList.tsx | 8 +- src/components/library/LibraryGameSearch.tsx | 17 +- .../library/LibraryLetterJumpModal.tsx | 4 +- .../library/LibraryMediaDetailsModal.tsx | 10 +- src/hooks/useActiveDeviceKey.ts | 21 + src/hooks/useDeviceLinking.ts | 13 +- src/hooks/useLibraryBrowse.ts | 4 +- src/hooks/useSelectDevice.ts | 110 +- src/lib/coreApi.ts | 239 +---- src/lib/crypto/credentials.ts | 183 ++-- src/lib/deviceUrl.ts | 26 - src/lib/devices/deviceRegistry.ts | 938 ++++++++++++++++++ src/lib/devices/endpoint.ts | 173 ++++ src/lib/libraryImageCache.ts | 24 +- src/lib/libraryImages.ts | 9 +- src/lib/rollbar.ts | 7 + src/lib/store.ts | 175 ---- src/routeTree.gen.ts | 34 +- src/routes/-pages/DeviceDetail.tsx | 110 +- src/routes/-pages/Devices.tsx | 127 +-- src/routes/library.$system.tsx | 27 +- src/routes/library.favorites.tsx | 15 +- src/routes/library.index.tsx | 11 +- ...ss.tsx => settings.devices_.$recordId.tsx} | 2 +- src/routes/settings.index.tsx | 40 +- src/test-setup.ts | 3 + src/test-utils/deviceRegistry.ts | 95 ++ src/test-utils/index.tsx | 6 +- src/translations/en-US.json | 1 + 88 files changed, 4010 insertions(+), 2700 deletions(-) create mode 100644 src/__tests__/unit/hooks/useActiveDeviceKey.test.tsx delete mode 100644 src/__tests__/unit/lib/coreApi.url.test.ts create mode 100644 src/__tests__/unit/lib/coreApi.validateAddress.test.ts create mode 100644 src/__tests__/unit/lib/devices/deviceRegistry.test.ts create mode 100644 src/__tests__/unit/lib/devices/endpoint.test.ts delete mode 100644 src/__tests__/unit/lib/storage.test.ts create mode 100644 src/hooks/useActiveDeviceKey.ts delete mode 100644 src/lib/deviceUrl.ts create mode 100644 src/lib/devices/deviceRegistry.ts create mode 100644 src/lib/devices/endpoint.ts rename src/routes/{settings.devices_.$address.tsx => settings.devices_.$recordId.tsx} (68%) create mode 100644 src/test-utils/deviceRegistry.ts diff --git a/src/__tests__/integration/connection-flow.test.tsx b/src/__tests__/integration/connection-flow.test.tsx index 4ae052a6..3a56870c 100644 --- a/src/__tests__/integration/connection-flow.test.tsx +++ b/src/__tests__/integration/connection-flow.test.tsx @@ -15,6 +15,10 @@ import { ConnectionContext, ConnectionContextValue, } from "@/hooks/useConnection"; +import { + seedActiveDevice, + seedDeviceRegistry, +} from "@/test-utils/deviceRegistry"; import { ReactNode } from "react"; // Test wrapper that provides connection context @@ -33,14 +37,13 @@ function ConnectionWrapper({ } describe("Connection Flow Integration", () => { - beforeEach(() => { + beforeEach(async () => { // Reset stores to initial state useStatusStore.setState({ ...useStatusStore.getState(), connected: false, connectionState: ConnectionState.IDLE, connectionError: "", - targetDeviceAddress: "", // Seed encryptionState as plaintext so connected-state assertions don't // hit the verifying UI gate (encryptionState === "unknown" -> connecting). encryptionState: "plaintext", @@ -51,13 +54,13 @@ describe("Connection Flow Integration", () => { _hasHydrated: true, }); - // Set a default device address for most tests - localStorage.setItem("deviceAddress", "192.168.1.100"); + // Most tests just need a device to be selected; the address is whatever + // its active endpoint resolves to. + await seedActiveDevice({ address: "192.168.1.100" }); }); afterEach(() => { vi.restoreAllMocks(); - localStorage.clear(); }); describe("connection state transitions", () => { @@ -207,13 +210,8 @@ describe("Connection Flow Integration", () => { }); describe("device address handling", () => { - it("should show placeholder text when no address is set on native platform", async () => { - // Clear localStorage to simulate no address - localStorage.removeItem("deviceAddress"); - - // Mock native platform so getDeviceAddress returns empty (on web it falls back to hostname) - const { Capacitor } = await import("@capacitor/core"); - vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); + it("should show placeholder text when no device has been saved", async () => { + await seedDeviceRegistry([]); const connectionValue: ConnectionContextValue = { activeConnection: null, @@ -234,14 +232,10 @@ describe("Connection Flow Integration", () => { expect( screen.getByText("settings.enterDeviceAddress"), ).toBeInTheDocument(); - - // Reset mock - vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); }); - it("should show device address in subtitle when connecting", () => { - // Set actual localStorage value for the test - localStorage.setItem("deviceAddress", "10.0.0.50"); + it("should show device address in subtitle when connecting", async () => { + await seedActiveDevice({ address: "10.0.0.50" }); const connectionValue: ConnectionContextValue = { activeConnection: null, @@ -259,9 +253,6 @@ describe("Connection Flow Integration", () => { ); expect(screen.getByText("10.0.0.50")).toBeInTheDocument(); - - // Clean up - localStorage.removeItem("deviceAddress"); }); }); diff --git a/src/__tests__/integration/home-page.test.tsx b/src/__tests__/integration/home-page.test.tsx index 6e8c8618..fdd7b45b 100644 --- a/src/__tests__/integration/home-page.test.tsx +++ b/src/__tests__/integration/home-page.test.tsx @@ -24,6 +24,7 @@ import { ConnectionContext, ConnectionContextValue, } from "@/hooks/useConnection"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; import { ReactNode } from "react"; function expectVisibleEmptyValues(regionName: string, count: number) { @@ -59,7 +60,7 @@ const connectedContext: ConnectionContextValue = { }; describe("Home Page Integration", () => { - beforeEach(() => { + beforeEach(async () => { // Seed a deterministic baseline for every store field these tests touch // so prior-test mutations cannot leak in. encryptionState: "plaintext" // keeps connected-state assertions out of the verifying UI gate @@ -86,12 +87,11 @@ describe("Home Page Integration", () => { showFilenames: false, }); - localStorage.setItem("deviceAddress", "192.168.1.100"); + await seedActiveDevice({ address: "192.168.1.100" }); }); afterEach(() => { vi.restoreAllMocks(); - localStorage.clear(); }); describe("Last Scanned Info", () => { diff --git a/src/__tests__/integration/index-route.test.tsx b/src/__tests__/integration/index-route.test.tsx index 022e19b3..d23300d0 100644 --- a/src/__tests__/integration/index-route.test.tsx +++ b/src/__tests__/integration/index-route.test.tsx @@ -25,6 +25,7 @@ import { ConnectionContext, ConnectionContextValue, } from "@/hooks/useConnection"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; function expectVisibleEmptyValues(regionName: string, count: number) { const region = screen.getByRole("region", { name: regionName }); @@ -205,7 +206,6 @@ vi.mock("@/lib/coreApi", () => ({ run: vi.fn().mockResolvedValue(undefined), mediaControl: vi.fn().mockResolvedValue(undefined), }, - getDeviceAddress: vi.fn(() => "192.168.1.100"), })); vi.mock("@/lib/toastUtils", () => ({ @@ -329,7 +329,7 @@ function seedPrimaryPlaylist({ } describe("Index Route Integration", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); // Drop the setWriteOpen callback captured from a prior Index render mockScanOperationsProps.current = null; @@ -403,12 +403,11 @@ describe("Index Route Integration", () => { // Reset announcer mock mockAnnounce.mockClear(); - localStorage.setItem("deviceAddress", "192.168.1.100"); + await seedActiveDevice({ address: "192.168.1.100" }); }); afterEach(() => { vi.restoreAllMocks(); - localStorage.clear(); }); describe("Page Structure", () => { diff --git a/src/__tests__/integration/network-scan-modal.test.tsx b/src/__tests__/integration/network-scan-modal.test.tsx index 1929a391..65b0a4ce 100644 --- a/src/__tests__/integration/network-scan-modal.test.tsx +++ b/src/__tests__/integration/network-scan-modal.test.tsx @@ -3,7 +3,6 @@ import { render, screen, waitFor, act } from "../../test-utils"; import userEvent from "@testing-library/user-event"; import { NetworkScanModal } from "@/components/NetworkScanModal"; import { Capacitor } from "@capacitor/core"; -import { credentialStore } from "@/lib/crypto/credentials"; import { __simulateDeviceDiscovered, type ZeroConfService, @@ -344,13 +343,10 @@ describe("NetworkScanModal", () => { }); describe("device selection", () => { - it("should select normalized hostname and register its IP credential fallback", async () => { + it("should hand over the hostname and the IP it resolved to", async () => { // Arrange const user = userEvent.setup(); const onSelectDevice = vi.fn(); - const registerFallback = vi - .spyOn(credentialStore, "registerFallback") - .mockImplementation(() => undefined); render( { // Act - Click the device card await user.click(screen.getByText("MiSTer")); - // Assert + // Assert — the registry needs both: the hostname is what the record is + // built around, the IP is what the socket can actually reach today. expect(onSelectDevice).toHaveBeenCalledWith( - expect.objectContaining({ address: "mister.local", name: "MiSTer" }), - ); - expect(registerFallback).toHaveBeenCalledWith( - "mister.local", - "192.168.1.100", + expect.objectContaining({ + hostname: "mister.local", + addresses: ["192.168.1.100"], + port: 7497, + name: "MiSTer", + }), ); }); - it("should include port in selection when not default", async () => { + it("should carry the announced port through to the selection", async () => { // Arrange const user = userEvent.setup(); const onSelectDevice = vi.fn(); @@ -433,7 +431,8 @@ describe("NetworkScanModal", () => { // Assert expect(onSelectDevice).toHaveBeenCalledWith( expect.objectContaining({ - address: "test-device.local:9000", + hostname: "test-device.local", + port: 9000, name: "Custom Device", }), ); @@ -470,9 +469,11 @@ describe("NetworkScanModal", () => { await user.click(await screen.findByText("Fallback Device")); + // With no hostname announced there is nothing but the IP to build on. expect(onSelectDevice).toHaveBeenCalledWith( expect.objectContaining({ - address: "192.168.1.100", + hostname: undefined, + addresses: ["192.168.1.100"], name: "Fallback Device", }), ); diff --git a/src/__tests__/unit/App.firebase-auth.test.tsx b/src/__tests__/unit/App.firebase-auth.test.tsx index 23934833..74140fbb 100644 --- a/src/__tests__/unit/App.firebase-auth.test.tsx +++ b/src/__tests__/unit/App.firebase-auth.test.tsx @@ -206,7 +206,6 @@ vi.mock("@/hooks/useDataCache", () => ({ })); vi.mock("@/lib/coreApi", () => ({ - getDeviceAddress: vi.fn(() => "192.168.1.100"), coreApi: { addListener: vi.fn(() => ({ remove: vi.fn() })) }, })); diff --git a/src/__tests__/unit/App.integration.test.tsx b/src/__tests__/unit/App.integration.test.tsx index 9d57b9c8..529c4964 100644 --- a/src/__tests__/unit/App.integration.test.tsx +++ b/src/__tests__/unit/App.integration.test.tsx @@ -168,7 +168,6 @@ vi.mock("@/lib/store", () => { }); vi.mock("@/lib/coreApi", () => ({ - getDeviceAddress: vi.fn(() => "192.168.1.100"), coreApi: { addListener: vi.fn(() => ({ remove: vi.fn() })), }, diff --git a/src/__tests__/unit/components/ConnectionProvider.test.tsx b/src/__tests__/unit/components/ConnectionProvider.test.tsx index 48cba6c9..702a49c5 100644 --- a/src/__tests__/unit/components/ConnectionProvider.test.tsx +++ b/src/__tests__/unit/components/ConnectionProvider.test.tsx @@ -12,7 +12,16 @@ import { act, render, screen, waitFor } from "../../../test-utils"; import { ConnectionProvider } from "../../../components/ConnectionProvider"; import { useConnection } from "../../../hooks/useConnection"; import { connectionManager } from "../../../lib/transport"; -import { CoreAPI, isCancelled } from "../../../lib/coreApi"; +import { CoreAPI } from "../../../lib/coreApi"; +import { + credentialKeyForRecord, + credentialStore, +} from "@/lib/crypto/credentials"; +import { deviceRegistry } from "@/lib/devices/deviceRegistry"; +import { + mockDeviceRecord, + seedDeviceRegistry, +} from "@/test-utils/deviceRegistry"; import { ConnectionState, useStatusStore } from "@/lib/store"; import type { TransportState } from "../../../lib/transport/types"; import type { NotificationRequest } from "../../../lib/coreApi"; @@ -25,6 +34,9 @@ let capturedEventHandlers: { onConnectionChange?: (deviceId: string, connection: unknown) => void; onMessage?: (deviceId: string, event: unknown) => void; onError?: (deviceId: string, error: Error) => void; + onEncryptedHandshakeOk?: () => void; + onPlaintextMode?: () => void; + onCredentialsRevoked?: () => void; } = {}; const pairingModalCapture = vi.hoisted(() => ({ @@ -113,8 +125,6 @@ vi.mock("../../../lib/coreApi", () => ({ paused: false, }), }, - getDeviceAddress: vi.fn(() => "192.168.1.100:7497"), - getWsUrl: vi.fn(() => "ws://192.168.1.100:7497"), validateDeviceAddress: vi.fn((address: string) => { if (address.includes("286")) { return { @@ -135,7 +145,15 @@ vi.mock("../../../lib/coreApi", () => ({ wsUrl: `ws://${host}:${port}/api/v0.1`, }; }), - isCancelled: vi.fn(() => false), + // Mirror the real predicate rather than a call counter: several responses are + // in flight at once after a connect, and a `mockReturnValueOnce` would attach + // itself to whichever happened to resolve first. + isCancelled: vi.fn( + (response: unknown) => + typeof response === "object" && + response !== null && + (response as { cancelled?: unknown }).cancelled === true, + ), isExpectedMediaDatabaseError: (error: unknown) => { if (!(error instanceof Error)) return false; const msg = error.message.toLowerCase(); @@ -150,6 +168,7 @@ vi.mock("@capacitor/preferences", () => ({ Preferences: { get: vi.fn().mockResolvedValue({ value: null }), set: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), }, })); @@ -225,18 +244,27 @@ function ConnectionConsumer() { ); } -// Pre-set targetDeviceAddress to skip the async polling initialization -function resetStore() { - useStatusStore.setState({ - ...useStatusStore.getInitialState(), - targetDeviceAddress: "192.168.1.100:7497", - }); +/** + * The transport keys devices by record id now, so the id the provider hands to + * `connectionManager` is this, not an address. Tests drive the connection + * handlers with it for the same reason. + */ +const RECORD_ID = "record-under-test"; +const DEVICE_ADDRESS = "192.168.1.100:7497"; + +/** A hydrated registry holding one device, so the connection effect can run. */ +async function resetStore() { + useStatusStore.setState({ ...useStatusStore.getInitialState() }); + await seedDeviceRegistry( + [mockDeviceRecord({ recordId: RECORD_ID, address: DEVICE_ADDRESS })], + RECORD_ID, + ); } describe("ConnectionProvider", () => { beforeEach(async () => { vi.clearAllMocks(); - resetStore(); + await resetStore(); const bridge = await import("@/lib/capacitorBridge"); vi.mocked(bridge.isPluginAvailable).mockReturnValue(true); @@ -278,9 +306,7 @@ describe("ConnectionProvider", () => { vi.mocked(bridge.isNativePluginAvailable).mockImplementation( (pluginName: string) => !["App", "Network"].includes(pluginName), ); - vi.mocked(connectionManager.getActiveDeviceId).mockReturnValue( - "192.168.1.100:7497", - ); + vi.mocked(connectionManager.getActiveDeviceId).mockReturnValue(RECORD_ID); render( @@ -292,7 +318,7 @@ describe("ConnectionProvider", () => { expect(connectionManager.setEventHandlers).toHaveBeenCalled(); }); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -326,9 +352,11 @@ describe("ConnectionProvider", () => { , ); + // The transport is keyed by the record, not the address it happens to be + // reachable at today — that is what lets a device survive a DHCP move. expect(connectionManager.addDevice).toHaveBeenCalledWith( expect.objectContaining({ - deviceId: "192.168.1.100:7497", + deviceId: RECORD_ID, type: "websocket", address: "ws://192.168.1.100:7497/api/v0.1", encryption: expect.objectContaining({ @@ -338,11 +366,8 @@ describe("ConnectionProvider", () => { ); }); - it("should not create a transport for invalid target address", () => { - useStatusStore.setState({ - ...useStatusStore.getInitialState(), - targetDeviceAddress: "192.168.1.286", - }); + it("should not create a transport when no device is active", async () => { + await seedDeviceRegistry([]); render( @@ -353,10 +378,7 @@ describe("ConnectionProvider", () => { expect(connectionManager.addDevice).not.toHaveBeenCalled(); expect(connectionManager.setActiveDevice).not.toHaveBeenCalled(); expect(useStatusStore.getState().connectionState).toBe( - ConnectionState.ERROR, - ); - expect(useStatusStore.getState().connectionError).toBe( - "settings.deviceAddressInvalid", + ConnectionState.DISCONNECTED, ); }); @@ -367,9 +389,7 @@ describe("ConnectionProvider", () => { , ); - expect(connectionManager.setActiveDevice).toHaveBeenCalledWith( - "192.168.1.100:7497", - ); + expect(connectionManager.setActiveDevice).toHaveBeenCalledWith(RECORD_ID); }); it("should restart the active connection after pairing succeeds", () => { @@ -398,9 +418,7 @@ describe("ConnectionProvider", () => { unmount(); - expect(connectionManager.removeDevice).toHaveBeenCalledWith( - "192.168.1.100:7497", - ); + expect(connectionManager.removeDevice).toHaveBeenCalledWith(RECORD_ID); }); }); @@ -428,9 +446,9 @@ describe("ConnectionProvider", () => { }); describe("useConnection hook", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); - resetStore(); + await resetStore(); }); it("should return connection context values with expected initial state", () => { @@ -454,7 +472,7 @@ describe("useConnection hook", () => { }); describe("notification processing", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); vi.mocked(CoreAPI.processReceived).mockReset().mockResolvedValue(null); vi.mocked(CoreAPI.media) @@ -464,7 +482,7 @@ describe("notification processing", () => { active: [], }); capturedEventHandlers = {}; - resetStore(); + await resetStore(); mockToast.mockClear(); mockAnnounce.mockClear(); }); @@ -1605,12 +1623,8 @@ describe("notification processing", () => { }, }); vi.mocked(CoreAPI.processReceived).mockReturnValueOnce(messagePromise); - vi.mocked(isCancelled).mockReturnValueOnce(true); vi.mocked(CoreAPI.media) - .mockResolvedValueOnce({ - database: { exists: true, indexing: true }, - active: [], - }) + .mockResolvedValueOnce({ cancelled: true } as any) .mockRejectedValueOnce(new Error("Temporary media status failure")) .mockResolvedValueOnce({ database: { @@ -1663,7 +1677,6 @@ describe("notification processing", () => { } finally { view.unmount(); loggerSpy.mockRestore(); - vi.mocked(isCancelled).mockReturnValue(false); vi.useRealTimers(); } }); @@ -1708,9 +1721,7 @@ describe("notification processing", () => { }); expect(removeSpy).toHaveBeenCalledWith({ queryKey: ["mediaImage"] }); await waitFor(() => - expect(invalidateLibraryImageCache).toHaveBeenCalledWith( - "192.168.1.100:7497", - ), + expect(invalidateLibraryImageCache).toHaveBeenCalledWith(RECORD_ID), ); invalidateSpy.mockRestore(); removeSpy.mockRestore(); @@ -1867,14 +1878,12 @@ describe("notification processing", () => { }); describe("connection event handling", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); capturedEventHandlers = {}; - resetStore(); + await resetStore(); // Re-setup mock after clearAllMocks - use the address from store mock - vi.mocked(connectionManager.getActiveDeviceId).mockReturnValue( - "192.168.1.100:7497", - ); + vi.mocked(connectionManager.getActiveDeviceId).mockReturnValue(RECORD_ID); }); it("should call handleConnectionOpen when connection state becomes connected", async () => { @@ -1887,7 +1896,7 @@ describe("connection event handling", () => { expect(capturedEventHandlers.onConnectionChange).toBeDefined(); // Simulate connection becoming connected using the correct device ID - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -1934,7 +1943,7 @@ describe("connection event handling", () => { try { await act(async () => { - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2016,7 +2025,7 @@ describe("connection event handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2079,7 +2088,7 @@ describe("connection event handling", () => { , ); act(() => { - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2139,7 +2148,7 @@ describe("connection event handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2160,7 +2169,7 @@ describe("connection event handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2196,7 +2205,7 @@ describe("connection event handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2233,7 +2242,7 @@ describe("connection event handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2242,7 +2251,7 @@ describe("connection event handling", () => { expect(CoreAPI.clientsCurrent).toHaveBeenCalledTimes(1); }); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "reconnecting", hasData: true, hasConnectedBefore: true, @@ -2277,7 +2286,7 @@ describe("connection event handling", () => { ); cancelSpy.mockClear(); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "reconnecting", hasData: true, hasConnectedBefore: true, @@ -2309,7 +2318,7 @@ describe("connection event handling", () => { }, }); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "reconnecting", hasData: true, hasConnectedBefore: true, @@ -2328,7 +2337,7 @@ describe("connection event handling", () => { ); invalidateSpy.mockClear(); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2372,7 +2381,7 @@ describe("connection event handling", () => { ); expect(capturedEventHandlers.onConnectionChange).toBeDefined(); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2408,7 +2417,7 @@ describe("connection event handling", () => { ); expect(capturedEventHandlers.onConnectionChange).toBeDefined(); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2446,7 +2455,7 @@ describe("connection event handling", () => { ); expect(capturedEventHandlers.onConnectionChange).toBeDefined(); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2475,7 +2484,7 @@ describe("connection event handling", () => { ); expect(capturedEventHandlers.onConnectionChange).toBeDefined(); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2516,7 +2525,7 @@ describe("connection event handling", () => { ); expect(capturedEventHandlers.onConnectionChange).toBeDefined(); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2528,7 +2537,7 @@ describe("connection event handling", () => { }); }); - it("should merge platform, version, and lastConnectedAt into deviceHistory entry", async () => { + it("should store the platform and version the peer reports on its record", async () => { render( @@ -2537,35 +2546,34 @@ describe("connection event handling", () => { expect(capturedEventHandlers.onConnectionChange).toBeDefined(); - const before = Date.now(); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, }); + // The device list renders these between connects, so they have to outlive + // the socket that fetched them. await waitFor(() => { - const entry = useStatusStore - .getState() - .deviceHistory.find((e) => e.address === "192.168.1.100:7497"); - expect(entry).toBeDefined(); - expect(entry!.platform).toBe("test"); - expect(entry!.version).toBe("2.5.0"); - expect(typeof entry!.lastConnectedAt).toBe("number"); - expect(entry!.lastConnectedAt!).toBeGreaterThanOrEqual(before); - }); - }); - - it("should preserve fresh metadata when stored deviceHistory hydrates from Preferences", async () => { - // Pre-existing history on disk has no metadata. The fix sequences the two - // chains so the version() merge runs after Preferences.get hydrates state - // — this test guards against regressing back to a parallel race where the - // stored hydrate would clobber the merged metadata. - const stored = JSON.stringify([ - { address: "192.168.1.100:7497", name: "Old Name" }, - { address: "10.0.0.1:7497" }, - ]); - vi.mocked(Preferences.get).mockResolvedValueOnce({ value: stored }); + expect(deviceRegistry.getSnapshot().records[RECORD_ID]).toMatchObject({ + platform: "test", + version: "2.5.0", + }); + }); + }); + + it("should leave the user's own name alone when the peer reports its metadata", async () => { + await seedDeviceRegistry( + [ + mockDeviceRecord({ + recordId: RECORD_ID, + address: DEVICE_ADDRESS, + name: "Old Name", + nameIsCustom: true, + }), + ], + RECORD_ID, + ); render( @@ -2573,27 +2581,79 @@ describe("connection event handling", () => { , ); - expect(capturedEventHandlers.onConnectionChange).toBeDefined(); - - const before = Date.now(); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, }); await waitFor(() => { - const history = useStatusStore.getState().deviceHistory; - const entry = history.find((e) => e.address === "192.168.1.100:7497"); - // Stored entry is preserved (name retained), AND fresh metadata merged. - expect(entry?.name).toBe("Old Name"); - expect(entry?.platform).toBe("test"); - expect(entry?.version).toBe("2.5.0"); - expect(typeof entry?.lastConnectedAt).toBe("number"); - expect(entry!.lastConnectedAt!).toBeGreaterThanOrEqual(before); - // Other stored entries are not lost. - expect(history.find((e) => e.address === "10.0.0.1:7497")).toBeDefined(); + expect(deviceRegistry.getSnapshot().records[RECORD_ID]).toMatchObject({ + name: "Old Name", + platform: "test", + }); + }); + }); + + it("should stamp the record as connected once the peer settles on plaintext", async () => { + render( + + + , + ); + + const before = Date.now(); + capturedEventHandlers.onPlaintextMode!(); + + await waitFor(() => { + const record = deviceRegistry.getSnapshot().records[RECORD_ID]; + expect(record?.lastConnectedAt).toBeGreaterThanOrEqual(before); + }); + }); + + it("should move a pre-V2 pairing onto the record once the peer authenticates with it", async () => { + // Nothing before the handshake proves the credential stored at an address + // belongs to this record — a recycled DHCP lease would look identical. The + // key only moves after the peer has actually authenticated with it. + await seedDeviceRegistry( + [ + mockDeviceRecord({ + recordId: RECORD_ID, + address: DEVICE_ADDRESS, + legacyCredentialKey: "192.168.1.100", + }), + ], + RECORD_ID, + ); + await credentialStore.set("192.168.1.100", { + authToken: "token-abc", + pairingKey: "a".repeat(64), + clientId: "client-uuid-1234", + pairedAt: 1700000000000, }); + + render( + + + , + ); + + const [config] = vi.mocked(connectionManager.addDevice).mock.calls.at(-1)!; + await expect(config.encryption!.getCredentials()).resolves.toMatchObject({ + authToken: "token-abc", + }); + + capturedEventHandlers.onEncryptedHandshakeOk!(); + + await waitFor(async () => { + await expect( + credentialStore.get(credentialKeyForRecord(RECORD_ID)), + ).resolves.toMatchObject({ authToken: "token-abc" }); + }); + expect(await credentialStore.get("192.168.1.100")).toBeNull(); + expect( + deviceRegistry.getSnapshot().records[RECORD_ID]?.legacyCredentialKey, + ).toBeUndefined(); }); it("should set coreVersion to null when version fetch fails", async () => { @@ -2607,7 +2667,7 @@ describe("connection event handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2673,14 +2733,12 @@ describe("connection event handling", () => { }); describe("cancelled request handling", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); capturedEventHandlers = {}; - resetStore(); + await resetStore(); // Ensure getActiveDeviceId returns the device we're testing with - vi.mocked(connectionManager.getActiveDeviceId).mockReturnValue( - "192.168.1.100:7497", - ); + vi.mocked(connectionManager.getActiveDeviceId).mockReturnValue(RECORD_ID); }); it("should handle cancelled media response gracefully", async () => { @@ -2693,7 +2751,7 @@ describe("cancelled request handling", () => { ); // Trigger connection open with the deviceId that matches our mock - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2712,10 +2770,6 @@ describe("cancelled request handling", () => { // resolve as cancelled (request reset). We schedule one delayed retry — // without it the settings card and store stay stale until the next // notification arrives. - const { isCancelled } = await import("../../../lib/coreApi"); - vi.mocked(isCancelled) - .mockReturnValueOnce(true) // first call: cancelled, schedules retry - .mockReturnValue(false); // retry: not cancelled, processed normally vi.mocked(CoreAPI.media) .mockResolvedValueOnce({ cancelled: true } as any) .mockResolvedValueOnce({ @@ -2732,7 +2786,7 @@ describe("cancelled request handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2750,15 +2804,12 @@ describe("cancelled request handling", () => { }); } finally { vi.useRealTimers(); - vi.mocked(isCancelled).mockReturnValue(false); } }); it("should clear a pending media retry timer on unmount", async () => { // If the user switches devices while a retry is pending the timer must // not fire and write stale data into the new connection's store. - const { isCancelled } = await import("../../../lib/coreApi"); - vi.mocked(isCancelled).mockReturnValueOnce(true); vi.mocked(CoreAPI.media).mockResolvedValueOnce({ cancelled: true } as any); vi.useFakeTimers({ shouldAdvanceTime: true }); @@ -2770,7 +2821,7 @@ describe("cancelled request handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2787,7 +2838,6 @@ describe("cancelled request handling", () => { expect(CoreAPI.media).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); - vi.mocked(isCancelled).mockReturnValue(false); } }); @@ -2803,7 +2853,7 @@ describe("cancelled request handling", () => { ); // Trigger connection open with the deviceId that matches our mock - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2822,11 +2872,9 @@ describe("API error handling", () => { beforeEach(async () => { vi.clearAllMocks(); capturedEventHandlers = {}; - resetStore(); + await resetStore(); // Ensure getActiveDeviceId returns the device we're testing with - vi.mocked(connectionManager.getActiveDeviceId).mockReturnValue( - "192.168.1.100:7497", - ); + vi.mocked(connectionManager.getActiveDeviceId).mockReturnValue(RECORD_ID); // Reset rate limiter so toast assertions aren't masked by inter-test cooldown. const { resetToastRateLimiter } = await import("@/lib/toastUtils"); resetToastRateLimiter(); @@ -2842,7 +2890,7 @@ describe("API error handling", () => { ); // Trigger connection open with the deviceId that matches our mock - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2867,7 +2915,7 @@ describe("API error handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2893,7 +2941,7 @@ describe("API error handling", () => { ); // Trigger connection open with the deviceId that matches our mock - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2916,7 +2964,7 @@ describe("API error handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2943,7 +2991,7 @@ describe("API error handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2981,7 +3029,7 @@ describe("API error handling", () => { , ); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: false, hasConnectedBefore: false, @@ -2991,12 +3039,12 @@ describe("API error handling", () => { expect(CoreAPI.tokens).toHaveBeenCalledTimes(1); }); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "reconnecting", hasData: true, hasConnectedBefore: true, }); - capturedEventHandlers.onConnectionChange!("192.168.1.100:7497", { + capturedEventHandlers.onConnectionChange!(RECORD_ID, { state: "connected", hasData: true, hasConnectedBefore: true, @@ -3026,7 +3074,7 @@ describe("app lifecycle handling", () => { vi.clearAllMocks(); resumeCallback = null; pauseCallback = null; - resetStore(); + await resetStore(); // Capture the callbacks passed to App.addListener const { App } = await import("@capacitor/app"); @@ -3120,7 +3168,7 @@ describe("app lifecycle handling", () => { describe("browser visibility handling (web platform)", () => { beforeEach(async () => { vi.clearAllMocks(); - resetStore(); + await resetStore(); const { Capacitor } = await import("@capacitor/core"); vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); @@ -3183,10 +3231,10 @@ describe("browser visibility handling (web platform)", () => { }); describe("edge cases", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); capturedEventHandlers = {}; - resetStore(); + await resetStore(); }); describe("stale connection events", () => { @@ -3235,7 +3283,7 @@ describe("network status handling (native platform)", () => { beforeEach(async () => { vi.clearAllMocks(); networkListener = null; - resetStore(); + await resetStore(); // Mock Capacitor as native platform const { Capacitor } = await import("@capacitor/core"); @@ -3356,7 +3404,7 @@ describe("processNotification error handling", () => { beforeEach(async () => { vi.clearAllMocks(); capturedEventHandlers = {}; - resetStore(); + await resetStore(); mockToast.mockClear(); mockToastError.mockClear(); // Reset toast rate limiter to ensure toast shows diff --git a/src/__tests__/unit/components/ConnectionStatusDisplay.test.tsx b/src/__tests__/unit/components/ConnectionStatusDisplay.test.tsx index 555e0905..0a5d6fb1 100644 --- a/src/__tests__/unit/components/ConnectionStatusDisplay.test.tsx +++ b/src/__tests__/unit/components/ConnectionStatusDisplay.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { render, screen } from "../../../test-utils"; import { ConnectionStatusDisplay } from "@/components/ConnectionStatusDisplay"; import { @@ -6,16 +6,9 @@ import { ConnectionContextValue, } from "@/hooks/useConnection"; import { useStatusStore } from "@/lib/store"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; import { ReactNode } from "react"; -vi.mock("@/lib/coreApi", async () => { - const actual = await vi.importActual("@/lib/coreApi"); - return { - ...actual, - getDeviceAddress: () => "192.168.1.100", - }; -}); - function wrap( children: ReactNode, value: Partial = {}, @@ -37,11 +30,14 @@ function wrap( } describe("ConnectionStatusDisplay encryption gate", () => { - beforeEach(() => { + beforeEach(async () => { useStatusStore.setState({ encryptionState: "unknown", pairingRequired: false, }); + // Without a saved device the component reports "disconnected" regardless of + // the connection context, so every state below needs one selected. + await seedActiveDevice({ address: "192.168.1.100" }); }); it("should show Connecting when isConnected=true but encryptionState is unknown", () => { diff --git a/src/__tests__/unit/components/DeviceConnectionCard.test.tsx b/src/__tests__/unit/components/DeviceConnectionCard.test.tsx index 6c43e272..ebacf9d2 100644 --- a/src/__tests__/unit/components/DeviceConnectionCard.test.tsx +++ b/src/__tests__/unit/components/DeviceConnectionCard.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { render, screen, fireEvent } from "../../../test-utils"; import { DeviceConnectionCard } from "@/components/DeviceConnectionCard"; import { useStatusStore } from "@/lib/store"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; // Mock useConnection hook const mockUseConnection = vi.fn(); @@ -18,7 +19,6 @@ vi.mock("@/lib/coreApi", () => ({ platform: "linux", }), }, - getDeviceAddress: vi.fn(() => "192.168.1.100"), })); // Mock TanStack Query @@ -71,8 +71,11 @@ describe("DeviceConnectionCard", () => { connectionError: "", }; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + // The card compares the typed address against the active record's, and the + // status display falls back to "disconnected" without one. + await seedActiveDevice({ address: "192.168.1.100" }); mockUseConnection.mockReturnValue({ isConnected: true, showConnecting: false, @@ -291,7 +294,7 @@ describe("DeviceConnectionCard", () => { it("should not call onAddressChange when Enter is pressed with same address", () => { const onAddressChange = vi.fn(); - // savedAddress from mock is "192.168.1.100" + // The active record is already at this address, so Enter is a no-op. render( ({ @@ -64,7 +65,6 @@ const { ConnectionState, mockStore } = vi.hoisted(() => { } as const; const mockStore = { connected: true, - targetDeviceAddress: "test-device", connectionState: ConnectionState.CONNECTED as string, gamesIndex: { indexing: false, @@ -103,11 +103,11 @@ vi.mock("../../../lib/store", () => ({ })); describe("MediaDatabaseCard", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + await seedActiveDevice({ recordId: "test-device" }); // Reset mock store state mockStore.connected = true; - mockStore.targetDeviceAddress = "test-device"; mockStore.connectionState = ConnectionState.CONNECTED; mockStore.gamesIndex = { indexing: false, diff --git a/src/__tests__/unit/components/MediaDetailsModal.test.tsx b/src/__tests__/unit/components/MediaDetailsModal.test.tsx index f838cb78..6c0d852e 100644 --- a/src/__tests__/unit/components/MediaDetailsModal.test.tsx +++ b/src/__tests__/unit/components/MediaDetailsModal.test.tsx @@ -6,6 +6,7 @@ import { CoreAPI } from "@/lib/coreApi"; import type { SearchResultGame } from "@/lib/models"; import { usePreferencesStore } from "@/lib/preferencesStore"; import { useStatusStore } from "@/lib/store"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; vi.mock("@/hooks/useHaptics", () => ({ useHaptics: () => ({ @@ -45,12 +46,12 @@ function renderModal( } describe("MediaDetailsModal", () => { - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); usePreferencesStore.setState({ showFilenames: false }); + await seedActiveDevice({ recordId: "device-a" }); useStatusStore.setState({ connected: true, - targetDeviceAddress: "device-a", coreVersion: "2.15.0", coreVersionPending: false, }); diff --git a/src/__tests__/unit/components/PageFrame.test.tsx b/src/__tests__/unit/components/PageFrame.test.tsx index bc9972f8..d1ea4057 100644 --- a/src/__tests__/unit/components/PageFrame.test.tsx +++ b/src/__tests__/unit/components/PageFrame.test.tsx @@ -243,7 +243,7 @@ describe("PageFrame", () => { const rootRoute = createRootRoute({ component: () => }); const itemRoute = createRoute({ getParentRoute: () => rootRoute, - path: "/settings/devices/$address", + path: "/settings/devices/$recordId", component: ItemPage, }); const history = createMemoryHistory({ @@ -270,8 +270,8 @@ describe("PageFrame", () => { await act(() => router.navigate({ - to: "/settings/devices/$address", - params: { address: "two" }, + to: "/settings/devices/$recordId", + params: { recordId: "two" }, }), ); await waitFor(() => { diff --git a/src/__tests__/unit/components/PairingModal.test.tsx b/src/__tests__/unit/components/PairingModal.test.tsx index 1557032a..7bf97676 100644 --- a/src/__tests__/unit/components/PairingModal.test.tsx +++ b/src/__tests__/unit/components/PairingModal.test.tsx @@ -3,10 +3,12 @@ import { render, screen, waitFor } from "@/test-utils"; import userEvent from "@testing-library/user-event"; import { PairingModal } from "@/components/PairingModal"; import { performPairing, PairingError } from "@/lib/crypto/pairing"; -import { credentialStore } from "@/lib/crypto/credentials"; +import { + credentialKeyForRecord, + credentialStore, +} from "@/lib/crypto/credentials"; import { Capacitor } from "@capacitor/core"; import { Device } from "@capacitor/device"; -import { useStatusStore } from "@/lib/store"; vi.mock("@/lib/crypto/pairing", async () => { const actual = await vi.importActual( @@ -18,16 +20,6 @@ vi.mock("@/lib/crypto/pairing", async () => { }; }); -vi.mock("@/lib/crypto/credentials", () => ({ - credentialStore: { - set: vi.fn().mockResolvedValue(undefined), - delete: vi.fn().mockResolvedValue(true), - get: vi.fn().mockResolvedValue(null), - list: vi.fn().mockResolvedValue([]), - }, - normalizeDeviceKey: (s: string) => s, -})); - vi.mock("@/lib/transport", () => ({ connectionManager: { immediateReconnectActive: vi.fn(), @@ -43,14 +35,9 @@ vi.mock("react-hot-toast", () => ({ })); const mockedPerformPairing = vi.mocked(performPairing); -const mockedCredentialStoreSet = vi.mocked(credentialStore.set); const mockedDeviceGetInfo = vi.mocked(Device.getInfo); -function setStoreHistory(address: string) { - useStatusStore.setState({ - deviceHistory: [{ address }], - }); -} +const RECORD_ID = "record-under-test"; describe("PairingModal", () => { beforeEach(() => { @@ -58,7 +45,6 @@ describe("PairingModal", () => { // Default to native platform so Device.getInfo is exercised. Individual // tests can override this for the web-fallback path. vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); - setStoreHistory("192.168.1.10:7497"); mockedDeviceGetInfo.mockResolvedValue({ name: "Pixel 8", model: "Pixel 8", @@ -78,6 +64,7 @@ describe("PairingModal", () => { isOpen={true} close={vi.fn()} address="192.168.1.10:7497" + recordId={RECORD_ID} />, ); @@ -91,6 +78,7 @@ describe("PairingModal", () => { isOpen={true} close={vi.fn()} address="192.168.1.10:7497" + recordId={RECORD_ID} />, ); @@ -98,11 +86,39 @@ describe("PairingModal", () => { }); it("should show noAddress message when address is empty", () => { - render(); + render( + , + ); expect(screen.getByText("pairing.noAddress")).toBeInTheDocument(); }); + it("should refuse to pair before the device has a record", async () => { + // Credentials are stored against the record, so pairing without one would + // produce a key nothing can ever look up again. + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByLabelText("pairing.pinLabel"), "123456"); + + expect( + screen.getByRole("button", { name: "pairing.startPairing" }), + ).toBeDisabled(); + expect(mockedPerformPairing).not.toHaveBeenCalled(); + }); + it("should disable Pair button until 6 digits are entered", async () => { const user = userEvent.setup(); render( @@ -110,6 +126,7 @@ describe("PairingModal", () => { isOpen={true} close={vi.fn()} address="192.168.1.10:7497" + recordId={RECORD_ID} />, ); @@ -132,6 +149,7 @@ describe("PairingModal", () => { isOpen={true} close={vi.fn()} address="192.168.1.10:7497" + recordId={RECORD_ID} />, ); @@ -161,6 +179,7 @@ describe("PairingModal", () => { isOpen={true} close={vi.fn()} address="192.168.1.10:7497" + recordId={RECORD_ID} />, ); @@ -181,6 +200,7 @@ describe("PairingModal", () => { isOpen={true} close={vi.fn()} address="192.168.1.10:7497" + recordId={RECORD_ID} />, ); @@ -202,6 +222,7 @@ describe("PairingModal", () => { isOpen={true} close={vi.fn()} address="192.168.1.10:7497" + recordId={RECORD_ID} />, ); @@ -230,6 +251,7 @@ describe("PairingModal", () => { isOpen={true} close={close} address="192.168.1.10:7497" + recordId={RECORD_ID} onSuccess={onSuccess} />, ); @@ -248,15 +270,16 @@ describe("PairingModal", () => { expect.stringContaining("Pixel 8"), ); - await waitFor(() => { - expect(mockedCredentialStoreSet).toHaveBeenCalledWith( - "192.168.1.10:7497", - expect.objectContaining({ - authToken: "test-token", - clientId: "client-abc", - pairingKey: "deadbeef", - }), - ); + // The pairing belongs to the record, not to the address it was performed + // over — that is what lets it survive the device moving. + await waitFor(async () => { + await expect( + credentialStore.get(credentialKeyForRecord(RECORD_ID)), + ).resolves.toMatchObject({ + authToken: "test-token", + clientId: "client-abc", + pairingKey: "deadbeef", + }); }); await waitFor(() => { @@ -264,39 +287,6 @@ describe("PairingModal", () => { expect(close).toHaveBeenCalledTimes(1); }); }); - - it("should mark the matching device history entry as paired", async () => { - const user = userEvent.setup(); - mockedPerformPairing.mockResolvedValue({ - authToken: "tok", - clientId: "cid", - pairingKey: new Uint8Array([0x01, 0x02]), - }); - - render( - , - ); - - // Typing 6 digits triggers PinInput.onComplete → handlePair, so do not - // also click the submit button — that's a duplicate submission path. - await user.type(screen.getByLabelText("pairing.pinLabel"), "111111"); - - await waitFor(() => { - const entry = useStatusStore - .getState() - .deviceHistory.find((e) => e.address === "192.168.1.10:7497"); - expect(entry?.paired).toEqual({ - clientId: "cid", - pairedAt: expect.any(Number), - }); - }); - - expect(mockedPerformPairing).toHaveBeenCalledTimes(1); - }); }); describe("error handling", () => { @@ -311,6 +301,7 @@ describe("PairingModal", () => { isOpen={true} close={vi.fn()} address="192.168.1.10:7497" + recordId={RECORD_ID} />, ); @@ -332,6 +323,7 @@ describe("PairingModal", () => { isOpen={true} close={vi.fn()} address="192.168.1.10:7497" + recordId={RECORD_ID} />, ); @@ -357,6 +349,7 @@ describe("PairingModal", () => { isOpen={true} close={close} address="192.168.1.10:7497" + recordId={RECORD_ID} onSuccess={onSuccess} />, ); diff --git a/src/__tests__/unit/components/SimpleSystemSelect.test.tsx b/src/__tests__/unit/components/SimpleSystemSelect.test.tsx index ea200427..3685c401 100644 --- a/src/__tests__/unit/components/SimpleSystemSelect.test.tsx +++ b/src/__tests__/unit/components/SimpleSystemSelect.test.tsx @@ -3,7 +3,8 @@ import { render, screen, waitFor, act } from "@/test-utils"; import userEvent from "@testing-library/user-event"; import { SimpleSystemSelect } from "@/components/SimpleSystemSelect"; import { CoreAPI } from "@/lib/coreApi"; -import { useStatusStore } from "@/lib/store"; +import { deviceRegistry } from "@/lib/devices/deviceRegistry"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; // Mock CoreAPI vi.mock("@/lib/coreApi", () => ({ @@ -24,8 +25,8 @@ const mockSystems = { }; describe("SimpleSystemSelect", () => { - beforeEach(() => { - useStatusStore.setState({ targetDeviceAddress: "device-a" }); + beforeEach(async () => { + await seedActiveDevice({ recordId: "device-a" }); vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); }); @@ -69,8 +70,8 @@ describe("SimpleSystemSelect", () => { await screen.findByRole("option", { name: "Super Nintendo" }), ).toBeInTheDocument(); - act(() => { - useStatusStore.setState({ targetDeviceAddress: "device-b" }); + await act(async () => { + await deviceRegistry.selectAddress("192.168.1.55"); }); expect( diff --git a/src/__tests__/unit/components/SystemSelector.test.tsx b/src/__tests__/unit/components/SystemSelector.test.tsx index 350959ab..5f793c72 100644 --- a/src/__tests__/unit/components/SystemSelector.test.tsx +++ b/src/__tests__/unit/components/SystemSelector.test.tsx @@ -17,6 +17,11 @@ import userEvent from "@testing-library/user-event"; import { useQuery } from "@tanstack/react-query"; import { useStatusStore } from "@/lib/store"; import { usePreferencesStore } from "@/lib/preferencesStore"; +import { + seedActiveDevice, + seedDeviceRegistry, + mockDeviceRecord, +} from "@/test-utils/deviceRegistry"; import { SystemSelector, SystemSelectorTrigger, @@ -96,16 +101,16 @@ describe("SystemSelector", () => { mode: "single" as const, }; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); vi.useFakeTimers({ shouldAdvanceTime: true }); mockIsLoading = false; // Reset stores usePreferencesStore.setState({ systemNameRegion: "auto" }); + await seedActiveDevice({ recordId: "test-device" }); useStatusStore.setState({ ...useStatusStore.getState(), - targetDeviceAddress: "test-device", gamesIndex: { exists: true, indexing: false, @@ -184,14 +189,16 @@ describe("SystemSelector", () => { expect(screen.getByRole("radio", { name: "3DO" })).toBeInTheDocument(); }); - it("should scope full system queries to the selected device", () => { - useStatusStore.setState({ targetDeviceAddress: "10.0.0.5:7497" }); + it("should scope full system queries to the selected device", async () => { + await seedDeviceRegistry([ + mockDeviceRecord({ recordId: "other-device" }), + ]); render(); expect(useQuery).toHaveBeenCalledWith( expect.objectContaining({ - queryKey: ["systems", "10.0.0.5:7497", { all: true }], + queryKey: ["systems", "other-device", { all: true }], enabled: true, staleTime: 0, }), diff --git a/src/__tests__/unit/components/TagSelector.test.tsx b/src/__tests__/unit/components/TagSelector.test.tsx index c09d634e..04fa6278 100644 --- a/src/__tests__/unit/components/TagSelector.test.tsx +++ b/src/__tests__/unit/components/TagSelector.test.tsx @@ -17,6 +17,11 @@ import userEvent from "@testing-library/user-event"; import { TagSelector, TagSelectorTrigger } from "@/components/TagSelector"; import { useStatusStore } from "@/lib/store"; import { usePreferencesStore } from "@/lib/preferencesStore"; +import { + mockDeviceRecord, + seedActiveDevice, + seedDeviceRegistry, +} from "@/test-utils/deviceRegistry"; import { CoreAPI, MalformedCoreResponseError } from "@/lib/coreApi"; import { TagInfo } from "@/lib/models"; @@ -83,13 +88,13 @@ describe("TagSelector", () => { selectedTags: [] as string[], }; - beforeEach(() => { + beforeEach(async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); // Reset stores usePreferencesStore.setState({ accessibleLists: false }); + await seedActiveDevice({ recordId: "device-a" }); useStatusStore.setState({ ...useStatusStore.getState(), - targetDeviceAddress: "device-a", gamesIndex: { exists: true, indexing: false, @@ -192,8 +197,8 @@ describe("TagSelector", () => { await screen.findByRole("checkbox", { name: /action/i }), ).toBeInTheDocument(); - act(() => { - useStatusStore.setState({ targetDeviceAddress: "device-b" }); + await act(async () => { + await seedDeviceRegistry([mockDeviceRecord({ recordId: "device-b" })]); }); expect( diff --git a/src/__tests__/unit/components/home/ConnectionStatus.test.tsx b/src/__tests__/unit/components/home/ConnectionStatus.test.tsx index c26de1ea..a1323253 100644 --- a/src/__tests__/unit/components/home/ConnectionStatus.test.tsx +++ b/src/__tests__/unit/components/home/ConnectionStatus.test.tsx @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "../../../../test-utils"; import { ConnectionStatus } from "../../../../components/home/ConnectionStatus"; import { useStatusStore } from "@/lib/store"; - -// Mock coreApi -const mockGetDeviceAddress = vi.fn(() => "192.168.1.100"); -vi.mock("../../../../lib/coreApi", () => ({ - getDeviceAddress: () => mockGetDeviceAddress(), -})); +import { + seedActiveDevice, + seedDeviceRegistry, +} from "@/test-utils/deviceRegistry"; // Mock useConnection hook const mockUseConnection = vi.fn(); @@ -51,8 +49,8 @@ vi.mock("@tanstack/react-router", () => ({ })); describe("ConnectionStatus", () => { - beforeEach(() => { - mockGetDeviceAddress.mockReturnValue("192.168.1.100"); + beforeEach(async () => { + await seedActiveDevice({ address: "192.168.1.100" }); mockUseConnection.mockReturnValue({ isConnected: false, showConnecting: false, @@ -75,8 +73,8 @@ describe("ConnectionStatus", () => { ); }); - it("renders disconnected state when no address is saved", () => { - mockGetDeviceAddress.mockReturnValue(""); + it("renders disconnected state when no device is saved", async () => { + await seedDeviceRegistry([]); render(); @@ -141,8 +139,7 @@ describe("ConnectionStatus", () => { ).toBeInTheDocument(); }); - it("renders disconnected state when address exists but not connected", () => { - mockGetDeviceAddress.mockReturnValue("192.168.1.100"); + it("renders disconnected state when a device is saved but not connected", () => { mockUseConnection.mockReturnValue({ isConnected: false, showConnecting: false, diff --git a/src/__tests__/unit/components/library/FavoriteButton.test.tsx b/src/__tests__/unit/components/library/FavoriteButton.test.tsx index 9eb10f03..8259f004 100644 --- a/src/__tests__/unit/components/library/FavoriteButton.test.tsx +++ b/src/__tests__/unit/components/library/FavoriteButton.test.tsx @@ -34,7 +34,6 @@ describe("FavoriteButton", () => { mockErrorToast.mockClear(); useStatusStore.setState({ connected: true, - targetDeviceAddress: "device-a", coreVersion: "2.15.0", coreVersionPending: false, }); @@ -50,7 +49,7 @@ describe("FavoriteButton", () => { , ); const button = screen.getByRole("button", { @@ -82,7 +81,7 @@ describe("FavoriteButton", () => { tags: [{ type: "user", tag: "favorite" }], })} fallbackSystemId="SNES" - targetDeviceAddress="device-a" + deviceKey="device-a" />, ); await user.click( @@ -109,7 +108,7 @@ describe("FavoriteButton", () => { , ); await user.click( @@ -129,7 +128,7 @@ describe("FavoriteButton", () => { , ); @@ -149,7 +148,7 @@ describe("FavoriteButton", () => { , ); diff --git a/src/__tests__/unit/components/library/LibraryArtwork.test.tsx b/src/__tests__/unit/components/library/LibraryArtwork.test.tsx index 68ed1574..5b856736 100644 --- a/src/__tests__/unit/components/library/LibraryArtwork.test.tsx +++ b/src/__tests__/unit/components/library/LibraryArtwork.test.tsx @@ -28,7 +28,7 @@ describe("LibraryArtwork", () => { systemId: "SNES", }} systemId="SNES" - targetDeviceAddress="device-a" + deviceKey="device-a" maxSize={320} priority="thumbnail" onAvailabilityChange={onAvailabilityChange} @@ -60,7 +60,7 @@ describe("LibraryArtwork", () => { systemId: "SNES", }} systemId="SNES" - targetDeviceAddress="device-a" + deviceKey="device-a" maxSize={320} priority="thumbnail" onAvailabilityChange={onAvailabilityChange} diff --git a/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx b/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx index dce6be54..70f2b717 100644 --- a/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx +++ b/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx @@ -59,7 +59,7 @@ function renderList( { ref={listRef} entries={entries} systemId="snes" - targetDeviceAddress="device-a" + deviceKey="device-a" scrollRef={{ current: document.createElement("div") }} hasNextPage={false} isFetchingNextPage={false} diff --git a/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx b/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx index 91c2df8d..1a447ec0 100644 --- a/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx +++ b/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx @@ -84,7 +84,7 @@ function renderModal( close: vi.fn(), entry: ENTRY, systemId: "SNES", - targetDeviceAddress: "device-a", + deviceKey: "device-a", ...overrides, }; return { ...render(), props }; @@ -102,7 +102,6 @@ describe("LibraryMediaDetailsModal", () => { vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false); useStatusStore.setState({ connected: true, - targetDeviceAddress: "device-a", coreVersion: "2.15.0", coreVersionPending: false, }); diff --git a/src/__tests__/unit/coreApi.internals.test.ts b/src/__tests__/unit/coreApi.internals.test.ts index b404179d..8d1766ea 100644 --- a/src/__tests__/unit/coreApi.internals.test.ts +++ b/src/__tests__/unit/coreApi.internals.test.ts @@ -1,23 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - CoreAPI, - MalformedCoreResponseError, - getDeviceAddress, - setDeviceAddress, - getWsUrl, -} from "../../lib/coreApi"; +import { CoreAPI, MalformedCoreResponseError } from "../../lib/coreApi"; import { Method } from "../../lib/models"; const mockSend = vi.fn(); -import { Preferences } from "@capacitor/preferences"; - -// Mock localStorage -const mockLocalStorage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), -}; // Mock Capacitor vi.mock("@capacitor/core", () => ({ @@ -26,30 +11,9 @@ vi.mock("@capacitor/core", () => ({ }, })); -// Mock Preferences -vi.mock("@capacitor/preferences", () => ({ - Preferences: { - set: vi.fn(), - get: vi.fn(), - }, -})); - describe("CoreAPI Internals", () => { beforeEach(() => { vi.clearAllMocks(); - // Reset mock implementations (not just call history) - mockLocalStorage.getItem.mockReset(); - mockLocalStorage.setItem.mockReset(); - - Object.defineProperty(window, "localStorage", { - value: mockLocalStorage, - writable: true, - }); - - Object.defineProperty(window, "location", { - value: { hostname: "test-hostname" }, - writable: true, - }); // Mock WebSocket connection as connected so requests are sent immediately CoreAPI.setWsInstance({ isConnected: true, send: mockSend } as any); @@ -59,44 +23,6 @@ describe("CoreAPI Internals", () => { vi.restoreAllMocks(); }); - describe("Storage error handling", () => { - it("should throw when localStorage fails in getDeviceAddress", () => { - // Make localStorage.getItem throw an error - mockLocalStorage.getItem.mockImplementation(() => { - throw new Error("localStorage failed"); - }); - - expect(() => getDeviceAddress()).toThrow("localStorage failed"); - }); - - it("should not throw when localStorage fails in setDeviceAddress", () => { - // Make localStorage.setItem throw an error - mockLocalStorage.setItem.mockImplementation(() => { - throw new Error("localStorage setItem failed"); - }); - - // Should not throw - expect(() => setDeviceAddress("test-address")).not.toThrow(); - }); - - it("should not throw when Preferences.set fails in setDeviceAddress", async () => { - // Make Preferences.set reject - vi.mocked(Preferences.set).mockRejectedValue( - new Error("Preferences failed"), - ); - - // Should not throw - expect(() => setDeviceAddress("test-address")).not.toThrow(); - }); - - it("should not throw for invalid saved device address", () => { - mockLocalStorage.getItem.mockReturnValue("192.168.1.286"); - - expect(() => getWsUrl()).not.toThrow(); - expect(getWsUrl()).toBe(""); - }); - }); - describe("setSend edge cases", () => { it("should not throw when setting invalid send function", () => { // Should not throw, just log error diff --git a/src/__tests__/unit/coreApi.write-operations.test.ts b/src/__tests__/unit/coreApi.write-operations.test.ts index 6cfc88f0..162543d0 100644 --- a/src/__tests__/unit/coreApi.write-operations.test.ts +++ b/src/__tests__/unit/coreApi.write-operations.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { CoreAPI } from "../../lib/coreApi"; -import { getWsUrl } from "../../lib/coreApi"; // Mock WebSocket const mockSend = vi.fn(); @@ -323,118 +322,3 @@ describe("CoreAPI Write Operations", () => { }); }); }); - -describe("getWsUrl - Enhanced URL parsing", () => { - let originalLocalStorage: Storage; - let originalLocation: Location; - - beforeEach(() => { - // Store original values - originalLocalStorage = window.localStorage; - originalLocation = window.location; - - // Create fresh localStorage mock for each test - const localStorageMock = { - getItem: vi.fn((key: string) => localStorageMock._store[key] || null), - setItem: vi.fn((key: string, value: string) => { - localStorageMock._store[key] = value; - }), - clear: vi.fn(() => { - localStorageMock._store = {}; - }), - _store: {} as { [key: string]: string }, - }; - - Object.defineProperty(window, "localStorage", { - value: localStorageMock, - writable: true, - configurable: true, - }); - - // Clear the mock store - localStorageMock.clear(); - }); - - afterEach(() => { - // Restore original values - Object.defineProperty(window, "localStorage", { - value: originalLocalStorage, - writable: true, - configurable: true, - }); - Object.defineProperty(window, "location", { - value: originalLocation, - writable: true, - configurable: true, - }); - }); - - it("should parse host and port from device address", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:8080"); - - const url = getWsUrl(); - - expect(url).toBe("ws://192.168.1.100:8080/api/v0.1"); - }); - - it("should use default port when not specified", () => { - localStorage.setItem("deviceAddress", "192.168.1.100"); - - const url = getWsUrl(); - - expect(url).toBe("ws://192.168.1.100:7497/api/v0.1"); - }); - - it("should handle IPv6 addresses correctly", () => { - localStorage.setItem("deviceAddress", "[::1]:8080"); - - const url = getWsUrl(); - - expect(url).toBe("ws://[::1]:8080/api/v0.1"); - }); - - it("should reject port that is out of range", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:99999"); - - const url = getWsUrl(); - - expect(url).toBe(""); - }); - - it("should reject malformed addresses with multiple colons that aren't valid IPv6", () => { - localStorage.setItem("deviceAddress", "my:host:name"); - - const url = getWsUrl(); - - // 'my:host:name' has multiple colons but isn't valid IPv6 (not hex segments) - // Should be rejected rather than wrapped in brackets - expect(url).toBe(""); - }); - - it("should return empty string for malformed address with no host", () => { - localStorage.setItem("deviceAddress", ":8080"); // No host - - const url = getWsUrl(); - - // Malformed address starting with colon returns empty string - expect(url).toBe(""); - }); - - it("should reject trailing colon", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:"); - - const url = getWsUrl(); - - expect(url).toBe(""); - }); - - it("should handle errors gracefully", () => { - // Clear localStorage to trigger fallback - localStorage.clear(); - - const url = getWsUrl(); - - // Without stored device address, it defaults to localhost - expect(url).toBe("ws://localhost:7497/api/v0.1"); - }); -}); diff --git a/src/__tests__/unit/hooks/useActiveDeviceKey.test.tsx b/src/__tests__/unit/hooks/useActiveDeviceKey.test.tsx new file mode 100644 index 00000000..d7579e86 --- /dev/null +++ b/src/__tests__/unit/hooks/useActiveDeviceKey.test.tsx @@ -0,0 +1,95 @@ +/** + * Unit Tests: useActiveDeviceKey + * + * This key namespaces every device-scoped cache in the app, so what it must + * never do is stay the same across a device switch or change while one device + * stays selected. + */ + +import { act, renderHook } from "@/test-utils"; +import { describe, expect, it } from "vitest"; +import { useActiveDeviceKey } from "@/hooks/useActiveDeviceKey"; +import { deviceRegistry } from "@/lib/devices/deviceRegistry"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; + +describe("useActiveDeviceKey", () => { + it("should be empty before the registry hydrates", () => { + const { result } = renderHook(() => useActiveDeviceKey()); + + expect(result.current).toBe(""); + }); + + it("should scope state by record ID rather than by address", async () => { + const { result } = renderHook(() => useActiveDeviceKey()); + + let recordId = ""; + await act(async () => { + const record = await deviceRegistry.selectAddress("192.168.1.10:7497"); + recordId = record?.recordId ?? ""; + }); + + expect(recordId).not.toBe(""); + expect(result.current).toBe(recordId); + expect(result.current).not.toBe("192.168.1.10:7497"); + }); + + it("should change when the user switches devices", async () => { + const first = await seedActiveDevice({ address: "192.168.1.10" }); + const { result } = renderHook(() => useActiveDeviceKey()); + expect(result.current).toBe(first.recordId); + + await act(async () => { + await deviceRegistry.selectAddress("192.168.1.11"); + }); + + expect(result.current).not.toBe(first.recordId); + expect(result.current).not.toBe(""); + }); + + it("should hold steady when the active device is renamed", async () => { + const record = await seedActiveDevice(); + const { result } = renderHook(() => useActiveDeviceKey()); + + await act(async () => { + await deviceRegistry.setCustomName(record.recordId, "Living Room"); + }); + + expect(result.current).toBe(record.recordId); + }); + + it("should follow the same device across an address change", async () => { + let recordId = ""; + await act(async () => { + const record = await deviceRegistry.selectDiscovered({ + discoveryId: "core-id", + hostname: "steamdeck.local", + addresses: ["10.0.0.206"], + port: 7497, + }); + recordId = record?.recordId ?? ""; + }); + const { result } = renderHook(() => useActiveDeviceKey()); + + await act(async () => { + await deviceRegistry.selectDiscovered({ + discoveryId: "core-id", + hostname: "steamdeck.local", + addresses: ["10.0.0.207"], + port: 7497, + }); + }); + + expect(result.current).toBe(recordId); + }); + + it("should empty when the active device is forgotten", async () => { + const record = await seedActiveDevice(); + const { result } = renderHook(() => useActiveDeviceKey()); + + await act(async () => { + await deviceRegistry.removeRecord(record.recordId); + }); + + expect(result.current).toBe(""); + }); +}); diff --git a/src/__tests__/unit/hooks/useDeviceLinking.test.ts b/src/__tests__/unit/hooks/useDeviceLinking.test.ts index df82cc4f..fdac484b 100644 --- a/src/__tests__/unit/hooks/useDeviceLinking.test.ts +++ b/src/__tests__/unit/hooks/useDeviceLinking.test.ts @@ -46,7 +46,6 @@ vi.mock("@/lib/coreApi", () => ({ settingsAuthClaim: mockSettingsAuthClaim, settingsAuthStatus: mockSettingsAuthStatus, }, - getDeviceAddress: () => "192.168.1.50", isRequestCancelledError: (error: unknown) => error instanceof Error && /cancelled|aborted/i.test(error.message), })); diff --git a/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx b/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx index e0ba5b0b..f7a81111 100644 --- a/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx +++ b/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx @@ -42,7 +42,7 @@ describe("useLibraryBrowse", () => { }); const { result } = renderHook(() => useLibraryBrowse({ - targetDeviceAddress: "device-a", + deviceKey: "device-a", systemId: "SNES", path: "", sort: "name-desc", @@ -79,7 +79,7 @@ describe("useLibraryBrowse", () => { }); const { result } = renderHook(() => useLibraryBrowse({ - targetDeviceAddress: "device-a", + deviceKey: "device-a", systemId: "SNES", path: "/roms/SNES", enabled: true, @@ -138,7 +138,7 @@ describe("useLibraryBrowse", () => { }); const { result } = renderHook(() => useLibraryBrowse({ - targetDeviceAddress: "device-a", + deviceKey: "device-a", systemId: "SNES", path: "/roms/SNES", sort: "filename-asc", @@ -192,7 +192,7 @@ describe("useLibraryBrowse", () => { ); const { result } = renderHook(() => useLibraryBrowse({ - targetDeviceAddress: "device-a", + deviceKey: "device-a", systemId: "SNES", path: "/roms/SNES", enabled: true, diff --git a/src/__tests__/unit/hooks/useSelectDevice.test.tsx b/src/__tests__/unit/hooks/useSelectDevice.test.tsx index fb9d55f7..67791317 100644 --- a/src/__tests__/unit/hooks/useSelectDevice.test.tsx +++ b/src/__tests__/unit/hooks/useSelectDevice.test.tsx @@ -1,244 +1,302 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, act } from "../../../test-utils"; - -const { - mockGetDeviceAddress, - mockSetDeviceAddress, - mockValidateDeviceAddress, - mockCoreReset, - mockPreferencesRemove, - mockResetConnectionState, - mockSetTargetDeviceAddress, - mockAddDeviceHistory, - mockUpdateDeviceHistoryMeta, - storeState, -} = vi.hoisted(() => ({ - mockGetDeviceAddress: vi.fn(() => "192.168.1.10:7497"), - mockSetDeviceAddress: vi.fn(), - mockValidateDeviceAddress: vi.fn((address: string): unknown => { - const [host = address, portInput] = address.split(":"); - const port = portInput ? Number(portInput) : 7497; - - return { - ok: true, - address, - host, - port, - wsUrl: `ws://${host}:${port}/api/v0.1`, - }; - }), - mockCoreReset: vi.fn(), - mockPreferencesRemove: vi.fn().mockResolvedValue(undefined), - mockResetConnectionState: vi.fn(), - mockSetTargetDeviceAddress: vi.fn(), - mockAddDeviceHistory: vi.fn(), - mockUpdateDeviceHistoryMeta: vi.fn(), - storeState: { - resetConnectionState: vi.fn(), - setTargetDeviceAddress: vi.fn(), - addDeviceHistory: vi.fn(), - updateDeviceHistoryMeta: vi.fn(), - }, -})); - -vi.mock("@capacitor/preferences", () => ({ - Preferences: { - remove: mockPreferencesRemove, - }, -})); - -vi.mock("@/lib/coreApi", () => ({ - CoreAPI: { reset: mockCoreReset }, - getDeviceAddress: () => mockGetDeviceAddress(), - setDeviceAddress: (v: string) => mockSetDeviceAddress(v), - validateDeviceAddress: (v: string) => mockValidateDeviceAddress(v), -})); - -vi.mock("@/lib/store", () => ({ - useStatusStore: (selector: (s: typeof storeState) => unknown) => - selector({ - resetConnectionState: mockResetConnectionState, - setTargetDeviceAddress: mockSetTargetDeviceAddress, - addDeviceHistory: mockAddDeviceHistory, - updateDeviceHistoryMeta: mockUpdateDeviceHistoryMeta, - } as unknown as typeof storeState), -})); +/** + * Unit Tests: useSelectDevice + * + * Switching devices is the one moment where everything cached for the old box + * has to go with it — connection state, in-flight API requests, query cache and + * the saved search filters, none of which mean anything on the new device. The + * other half of the contract matters just as much: picking the device you are + * already on must not tear any of that down. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { Preferences } from "@capacitor/preferences"; +import { QueryClient } from "@tanstack/react-query"; +import { + act, + createProvidersWithQueryClient, + renderHook, + waitFor, +} from "@/test-utils"; import { useSelectDevice } from "@/hooks/useSelectDevice"; +import { CoreAPI } from "@/lib/coreApi"; +import { deviceRegistry } from "@/lib/devices/deviceRegistry"; +import { ConnectionState, useStatusStore } from "@/lib/store"; +import { + mockDeviceRecord, + seedActiveDevice, + seedDeviceRegistry, +} from "@/test-utils/deviceRegistry"; + +const CACHED_QUERY_KEY = ["media", "search"]; + +let queryClient: QueryClient; + +function renderSelectDevice() { + return renderHook(() => useSelectDevice(), { + wrapper: createProvidersWithQueryClient(queryClient), + }); +} + +/** + * `selectDevice` hands back its validation result before the registry write it + * kicks off has resolved, so tests that assert nothing happened have to let that + * write finish first. Every step of it is a microtask, so draining the queue is + * enough — there is nothing to wait on the clock for. + */ +async function settleSelection(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +/** Everything the teardown is supposed to clear, all present. */ +async function primeDeviceScopedState(): Promise { + useStatusStore.setState({ + connectionState: ConnectionState.CONNECTED, + connected: true, + connectionError: "previous device error", + }); + CoreAPI.setWsInstance({ + isConnected: true, + send: () => {}, + } as unknown as Parameters[0]); + queryClient.setQueryData(CACHED_QUERY_KEY, { results: [] }); + await Preferences.set({ key: "searchSystem", value: "snes" }); + await Preferences.set({ key: "searchTags", value: "favourite" }); +} + +async function deviceScopedStateWasCleared(): Promise { + const { connectionState, connectionError } = useStatusStore.getState(); + const [searchSystem, searchTags] = await Promise.all([ + Preferences.get({ key: "searchSystem" }), + Preferences.get({ key: "searchTags" }), + ]); + + return ( + connectionState === ConnectionState.IDLE && + connectionError === "" && + !CoreAPI.isConnected() && + queryClient.getQueryState(CACHED_QUERY_KEY)?.isInvalidated === true && + searchSystem.value === null && + searchTags.value === null + ); +} describe("useSelectDevice", () => { beforeEach(() => { - vi.clearAllMocks(); - mockGetDeviceAddress.mockReturnValue("192.168.1.10:7497"); - mockValidateDeviceAddress.mockImplementation((address: string) => { - const [host = address, portInput] = address.split(":"); - const port = portInput ? Number(portInput) : 7497; - - return { - ok: true, - address, - host, - port, - wsUrl: `ws://${host}:${port}/api/v0.1`, - }; + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, }); }); describe("selectDevice", () => { - it("should short-circuit when the new address equals the current address", () => { - const { result } = renderHook(() => useSelectDevice()); + it("should make the typed address the active device", async () => { + const { result } = renderSelectDevice(); - let selectionResult: unknown; + let validation: unknown; act(() => { - selectionResult = result.current.selectDevice("192.168.1.10:7497"); + validation = result.current.selectDevice("10.0.0.5:7497"); }); - expect(mockSetDeviceAddress).not.toHaveBeenCalled(); - expect(mockResetConnectionState).not.toHaveBeenCalled(); - expect(mockSetTargetDeviceAddress).not.toHaveBeenCalled(); - expect(mockCoreReset).not.toHaveBeenCalled(); - expect(mockPreferencesRemove).not.toHaveBeenCalled(); - expect(selectionResult).toMatchObject({ - ok: true, - address: "192.168.1.10:7497", + // The default port is implied, so it never survives into the address the + // user is shown or the endpoint the record stores. + expect(validation).toMatchObject({ ok: true, address: "10.0.0.5" }); + await waitFor(() => { + expect(deviceRegistry.activeEndpoint()?.address).toBe("10.0.0.5"); }); }); - it("should reset connection, target, API state, and search filters when switching devices", () => { - const { result } = renderHook(() => useSelectDevice()); + it("should clear state scoped to the device being left", async () => { + await seedActiveDevice({ address: "192.168.1.10" }); + await primeDeviceScopedState(); + const { result } = renderSelectDevice(); - act(() => result.current.selectDevice("10.0.0.5:7497")); - - expect(mockSetDeviceAddress).toHaveBeenCalledWith("10.0.0.5:7497"); - expect(mockResetConnectionState).toHaveBeenCalledTimes(1); - expect(mockSetTargetDeviceAddress).toHaveBeenCalledWith("10.0.0.5:7497"); - expect(mockCoreReset).toHaveBeenCalledTimes(1); - expect(mockPreferencesRemove).toHaveBeenCalledWith({ - key: "searchSystem", + act(() => { + result.current.selectDevice("10.0.0.5"); }); - expect(mockPreferencesRemove).toHaveBeenCalledWith({ - key: "searchTags", + + await waitFor(async () => { + expect(await deviceScopedStateWasCleared()).toBe(true); }); }); - it("should save normalized address when switching devices", () => { - mockValidateDeviceAddress.mockReturnValue({ - ok: true, - address: "10.0.0.5:8080", - host: "10.0.0.5", - port: 8080, - wsUrl: "ws://10.0.0.5:8080/api/v0.1", + it("should keep device state when the address is already active", async () => { + await seedActiveDevice({ address: "192.168.1.10" }); + await primeDeviceScopedState(); + const { result } = renderSelectDevice(); + + act(() => { + result.current.selectDevice("192.168.1.10:7497"); }); - const { result } = renderHook(() => useSelectDevice()); + await settleSelection(); - act(() => result.current.selectDevice(" http://10.0.0.5:8080/api/v0.1 ")); + expect(await deviceScopedStateWasCleared()).toBe(false); + expect(useStatusStore.getState().connectionError).toBe( + "previous device error", + ); + expect(queryClient.getQueryState(CACHED_QUERY_KEY)?.isInvalidated).toBe( + false, + ); + }); + + it("should store the normalized form of the address the user typed", async () => { + const { result } = renderSelectDevice(); + + act(() => { + result.current.selectDevice(" http://10.0.0.5:8080/api/v0.1 "); + }); - expect(mockSetDeviceAddress).toHaveBeenCalledWith("10.0.0.5:8080"); - expect(mockSetTargetDeviceAddress).toHaveBeenCalledWith("10.0.0.5:8080"); + await waitFor(() => { + expect(deviceRegistry.activeEndpoint()?.address).toBe("10.0.0.5:8080"); + }); }); - it("should not save or reconnect when address is invalid", () => { - mockValidateDeviceAddress.mockReturnValue({ + it("should reject an invalid address without touching any state", async () => { + await seedActiveDevice({ address: "192.168.1.10" }); + await primeDeviceScopedState(); + const { result } = renderSelectDevice(); + + let validation: unknown; + act(() => { + validation = result.current.selectDevice("192.168.1.286"); + }); + await settleSelection(); + + expect(validation).toMatchObject({ ok: false, errorKey: "settings.deviceAddressInvalid", - message: "Invalid device address", }); - const { result } = renderHook(() => useSelectDevice()); + expect(deviceRegistry.activeEndpoint()?.address).toBe("192.168.1.10"); + expect(await deviceScopedStateWasCleared()).toBe(false); + }); - let selectionResult: unknown; + it("should report an empty address as required rather than invalid", () => { + const { result } = renderSelectDevice(); + + let validation: unknown; act(() => { - selectionResult = result.current.selectDevice("192.168.1.286"); + validation = result.current.selectDevice(" "); }); - expect(selectionResult).toMatchObject({ + expect(validation).toMatchObject({ ok: false, - errorKey: "settings.deviceAddressInvalid", + errorKey: "settings.deviceAddressRequired", }); - expect(mockSetDeviceAddress).not.toHaveBeenCalled(); - expect(mockResetConnectionState).not.toHaveBeenCalled(); - expect(mockSetTargetDeviceAddress).not.toHaveBeenCalled(); - expect(mockCoreReset).not.toHaveBeenCalled(); - expect(mockPreferencesRemove).not.toHaveBeenCalled(); }); }); describe("selectScanDevice", () => { - it("should capture scan metadata immediately after selecting a new device", () => { - const { result } = renderHook(() => useSelectDevice()); + it("should activate the scanned device and keep the metadata it announced", async () => { + const { result } = renderSelectDevice(); - act(() => - result.current.selectScanDevice({ - address: "10.0.0.5:7497", + await act(async () => { + await result.current.selectScanDevice({ + discoveryId: "living-room._zaparoo._tcp.", + hostname: "living-room.local", + addresses: ["10.0.0.5"], + port: 7497, name: "Living Room", platform: "linux", version: "1.2.3", - }), - ); + }); + }); - expect(mockSetDeviceAddress).toHaveBeenCalledWith("10.0.0.5:7497"); - expect(mockAddDeviceHistory).toHaveBeenCalledWith("10.0.0.5:7497"); - expect(mockUpdateDeviceHistoryMeta).toHaveBeenCalledWith( - "10.0.0.5:7497", - { - name: "Living Room", - platform: "linux", - version: "1.2.3", - }, + const active = deviceRegistry.activeRecord(); + expect(active).toMatchObject({ + name: "Living Room", + platform: "linux", + version: "1.2.3", + }); + expect(deviceRegistry.activeEndpoint()?.address).toBe( + "living-room.local", ); }); - it("should still record metadata when the scan device matches the current address", () => { - // selectDevice short-circuits, but selectScanDevice still wants to capture - // freshly-discovered metadata onto the existing history entry. - const { result } = renderHook(() => useSelectDevice()); + it("should clear state scoped to the device being left", async () => { + await seedActiveDevice({ address: "192.168.1.10" }); + await primeDeviceScopedState(); + const { result } = renderSelectDevice(); - act(() => - result.current.selectScanDevice({ - address: "192.168.1.10:7497", - name: "Office", - platform: "linux", - version: "1.0.0", - }), - ); + await act(async () => { + await result.current.selectScanDevice({ + discoveryId: "living-room._zaparoo._tcp.", + hostname: "living-room.local", + addresses: ["10.0.0.5"], + port: 7497, + }); + }); - expect(mockResetConnectionState).not.toHaveBeenCalled(); - expect(mockAddDeviceHistory).toHaveBeenCalledWith("192.168.1.10:7497"); - expect(mockUpdateDeviceHistoryMeta).toHaveBeenCalledWith( - "192.168.1.10:7497", - { - name: "Office", - platform: "linux", - version: "1.0.0", - }, - ); + expect(await deviceScopedStateWasCleared()).toBe(true); }); - it("should not save or write history when scanned address is invalid", () => { - mockValidateDeviceAddress.mockReturnValue({ - ok: false, - errorKey: "settings.deviceAddressInvalid", - message: "Invalid device address", + it("should keep device state when the scan re-announces the active device", async () => { + const { result } = renderSelectDevice(); + const announcement = { + discoveryId: "living-room._zaparoo._tcp.", + hostname: "living-room.local", + addresses: ["10.0.0.5"], + port: 7497, + }; + + await act(async () => { + await result.current.selectScanDevice(announcement); }); - const { result } = renderHook(() => useSelectDevice()); + await primeDeviceScopedState(); - let selectionResult: unknown; - act(() => { - selectionResult = result.current.selectScanDevice({ - address: "192.168.1.286", + await act(async () => { + await result.current.selectScanDevice(announcement); + }); + + expect(await deviceScopedStateWasCleared()).toBe(false); + }); + + it("should ignore an announcement with no usable address", async () => { + await seedActiveDevice({ address: "192.168.1.10" }); + await primeDeviceScopedState(); + const { result } = renderSelectDevice(); + + await act(async () => { + await result.current.selectScanDevice({ + discoveryId: "broken._zaparoo._tcp.", + addresses: [], + port: 7497, }); }); - expect(selectionResult).toMatchObject({ - ok: false, - errorKey: "settings.deviceAddressInvalid", + expect(deviceRegistry.activeEndpoint()?.address).toBe("192.168.1.10"); + expect(await deviceScopedStateWasCleared()).toBe(false); + }); + }); + + describe("selectRecord", () => { + it("should activate a stored record and clear the previous device's state", async () => { + const other = mockDeviceRecord({ address: "192.168.1.10" }); + const target = mockDeviceRecord({ address: "192.168.1.11" }); + await seedDeviceRegistry([other, target], other.recordId); + await primeDeviceScopedState(); + const { result } = renderSelectDevice(); + + await act(async () => { + await result.current.selectRecord(target.recordId); + }); + + expect(deviceRegistry.getSnapshot().activeRecordId).toBe(target.recordId); + expect(await deviceScopedStateWasCleared()).toBe(true); + }); + + it("should do nothing for a record that no longer exists", async () => { + const record = await seedActiveDevice({ address: "192.168.1.10" }); + await primeDeviceScopedState(); + const { result } = renderSelectDevice(); + + await act(async () => { + await result.current.selectRecord("record-that-was-forgotten"); }); - expect(mockSetDeviceAddress).not.toHaveBeenCalled(); - expect(mockSetTargetDeviceAddress).not.toHaveBeenCalled(); - expect(mockResetConnectionState).not.toHaveBeenCalled(); - expect(mockCoreReset).not.toHaveBeenCalled(); - expect(mockPreferencesRemove).not.toHaveBeenCalled(); - expect(mockAddDeviceHistory).not.toHaveBeenCalled(); - expect(mockUpdateDeviceHistoryMeta).not.toHaveBeenCalled(); + + expect(deviceRegistry.getSnapshot().activeRecordId).toBe(record.recordId); + expect(await deviceScopedStateWasCleared()).toBe(false); }); }); }); diff --git a/src/__tests__/unit/lib/coreApi.playtime.test.ts b/src/__tests__/unit/lib/coreApi.playtime.test.ts index c58e3ae6..cb738abe 100644 --- a/src/__tests__/unit/lib/coreApi.playtime.test.ts +++ b/src/__tests__/unit/lib/coreApi.playtime.test.ts @@ -5,9 +5,8 @@ * that were missing coverage. */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { CoreAPI, setDeviceAddress } from "@/lib/coreApi"; -import { Preferences } from "@capacitor/preferences"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { CoreAPI } from "@/lib/coreApi"; // Mock Capacitor vi.mock("@capacitor/core", () => ({ @@ -16,19 +15,6 @@ vi.mock("@capacitor/core", () => ({ }, })); -// Mock localStorage -const localStorageMock = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), -}; - -Object.defineProperty(window, "localStorage", { - value: localStorageMock, - writable: true, -}); - describe("CoreAPI playtime methods", () => { let mockSend: ReturnType; @@ -178,77 +164,3 @@ describe("CoreAPI utility methods", () => { }); }); }); - -describe("setDeviceAddress", () => { - beforeEach(() => { - vi.clearAllMocks(); - localStorageMock.setItem.mockClear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("should save address to localStorage", () => { - setDeviceAddress("192.168.1.200"); - - expect(localStorageMock.setItem).toHaveBeenCalledWith( - "deviceAddress", - "192.168.1.200", - ); - }); - - it("should save address to Preferences", async () => { - setDeviceAddress("192.168.1.200:8080"); - - // Wait for the async Preferences.set to be called - await vi.waitFor(() => { - expect(Preferences.set).toHaveBeenCalledWith({ - key: "deviceAddress", - value: "192.168.1.200:8080", - }); - }); - }); - - it("should handle localStorage error gracefully", () => { - localStorageMock.setItem.mockImplementationOnce(() => { - throw new Error("Storage full"); - }); - - // Should not throw - expect(() => setDeviceAddress("192.168.1.100")).not.toThrow(); - }); - - it("should handle Preferences.set error gracefully", async () => { - vi.mocked(Preferences.set).mockRejectedValueOnce( - new Error("Storage error"), - ); - - // Should not throw - expect(() => setDeviceAddress("192.168.1.100")).not.toThrow(); - }); - - it("should save empty string address", () => { - setDeviceAddress(""); - - expect(localStorageMock.setItem).toHaveBeenCalledWith("deviceAddress", ""); - }); - - it("should save address with port", () => { - setDeviceAddress("192.168.1.100:8080"); - - expect(localStorageMock.setItem).toHaveBeenCalledWith( - "deviceAddress", - "192.168.1.100:8080", - ); - }); - - it("should save hostname address", () => { - setDeviceAddress("zaparoo.local"); - - expect(localStorageMock.setItem).toHaveBeenCalledWith( - "deviceAddress", - "zaparoo.local", - ); - }); -}); diff --git a/src/__tests__/unit/lib/coreApi.test.ts b/src/__tests__/unit/lib/coreApi.test.ts index 03486a5e..e548e4e6 100644 --- a/src/__tests__/unit/lib/coreApi.test.ts +++ b/src/__tests__/unit/lib/coreApi.test.ts @@ -3,39 +3,15 @@ import { CoreAPI, CoreApiError, MalformedCoreResponseError, - getDeviceAddress, - getWsUrl, isExpectedMediaDatabaseError, isMissingMediaDatabaseSetupError, isUnsupportedMediaApiError, } from "@/lib/coreApi"; -import { Capacitor } from "@capacitor/core"; import { Method, Notification } from "@/lib/models.ts"; // Mock Capacitor vi.mock("@capacitor/core"); -// Mock localStorage -const localStorageMock = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), -}; - -Object.defineProperty(window, "localStorage", { - value: localStorageMock, - writable: true, -}); - -// Mock window.location -Object.defineProperty(window, "location", { - value: { - hostname: "localhost", - }, - writable: true, -}); - describe("media API error classification", () => { it("should recognize unsupported media API errors case-insensitively", () => { expect(isUnsupportedMediaApiError(new Error("Method not found"))).toBe( @@ -101,7 +77,6 @@ describe("CoreAPI", () => { // Clear mocks vi.clearAllMocks(); - localStorageMock.getItem.mockReturnValue(""); }); afterEach(() => { @@ -184,22 +159,6 @@ describe("CoreAPI", () => { vi.useRealTimers(); }); - it("should return stored address from localStorage when available", () => { - localStorageMock.getItem.mockReturnValue("192.168.1.100"); - - const address = getDeviceAddress(); - expect(address).toBe("192.168.1.100"); - expect(localStorageMock.getItem).toHaveBeenCalledWith("deviceAddress"); - }); - - it("should return hostname when on web platform and no stored address", () => { - vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); - localStorageMock.getItem.mockReturnValue(""); - - const address = getDeviceAddress(); - expect(address).toBe("localhost"); - }); - it("should handle pong messages in processReceived", async () => { const pongEvent = { data: "pong" } as MessageEvent; const result = await CoreAPI.processReceived(pongEvent); @@ -526,93 +485,4 @@ describe("CoreAPI", () => { const sentData = JSON.parse(mockSend.mock.calls[0][0]); expect(sentData.method).toBe("readers"); }); - - describe("getWsUrl", () => { - it("should use default port 7497 when address has no port", () => { - localStorageMock.getItem.mockReturnValue("192.168.1.100"); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe("ws://192.168.1.100:7497/api/v0.1"); - }); - - it("should use custom port when address includes port", () => { - localStorageMock.getItem.mockReturnValue("192.168.1.100:8080"); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe("ws://192.168.1.100:8080/api/v0.1"); - }); - - it("should handle hostname with custom port", () => { - localStorageMock.getItem.mockReturnValue("zaparoo.local:9090"); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe("ws://zaparoo.local:9090/api/v0.1"); - }); - - it("should reject non-numeric port", () => { - localStorageMock.getItem.mockReturnValue("192.168.1.100:abc"); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe(""); - }); - - it("should reject port that is out of range", () => { - localStorageMock.getItem.mockReturnValue("192.168.1.100:70000"); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe(""); - }); - - it("should reject zero port", () => { - localStorageMock.getItem.mockReturnValue("192.168.1.100:0"); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe(""); - }); - - it("should handle unbracketed IPv6 addresses by wrapping in brackets", () => { - localStorageMock.getItem.mockReturnValue("::1"); - - const wsUrl = getWsUrl(); - // Unbracketed IPv6 addresses should be wrapped in brackets with default port - expect(wsUrl).toBe("ws://[::1]:7497/api/v0.1"); - }); - - it("should handle addresses with multiple colons as IPv6", () => { - // Addresses with multiple colons are treated as IPv6 and wrapped in brackets - localStorageMock.getItem.mockReturnValue("fe80::1"); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe("ws://[fe80::1]:7497/api/v0.1"); - }); - - it("should reject trailing colon", () => { - localStorageMock.getItem.mockReturnValue("192.168.1.100:"); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe(""); - }); - - it("should use localhost with default port when no address is stored and on web", () => { - vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); - localStorageMock.getItem.mockReturnValue(""); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe("ws://localhost:7497/api/v0.1"); - }); - - it("should handle edge case port numbers", () => { - localStorageMock.getItem.mockReturnValue("192.168.1.100:1"); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe("ws://192.168.1.100:1/api/v0.1"); - }); - - it("should handle maximum valid port number", () => { - localStorageMock.getItem.mockReturnValue("192.168.1.100:65535"); - - const wsUrl = getWsUrl(); - expect(wsUrl).toBe("ws://192.168.1.100:65535/api/v0.1"); - }); - }); }); diff --git a/src/__tests__/unit/lib/coreApi.url.test.ts b/src/__tests__/unit/lib/coreApi.url.test.ts deleted file mode 100644 index 4dc909e1..00000000 --- a/src/__tests__/unit/lib/coreApi.url.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Unit Tests: CoreAPI URL Parsing - * - * Tests for URL parsing and construction functions in coreApi. - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { - getWsUrl, - getDeviceAddress, - setDeviceAddress, - validateDeviceAddress, -} from "@/lib/coreApi"; - -describe("CoreAPI URL Functions", () => { - beforeEach(() => { - // Clear localStorage before each test - localStorage.clear(); - }); - - afterEach(() => { - localStorage.clear(); - vi.restoreAllMocks(); - }); - - describe("getDeviceAddress", () => { - it("should return empty string when no address is stored on native", async () => { - // Mock native platform - const { Capacitor } = await import("@capacitor/core"); - vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); - - const address = getDeviceAddress(); - expect(address).toBe(""); - - // Reset mock - vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); - }); - - it("should return stored address from localStorage", () => { - localStorage.setItem("deviceAddress", "192.168.1.100"); - - const address = getDeviceAddress(); - expect(address).toBe("192.168.1.100"); - }); - - it("should return hostname on web platform when no address stored", () => { - // On web (non-native), it should fall back to window.location.hostname - const address = getDeviceAddress(); - // In test environment, hostname is typically 'localhost' or similar - expect(typeof address).toBe("string"); - }); - }); - - describe("setDeviceAddress", () => { - it("should store address in localStorage", () => { - setDeviceAddress("10.0.0.50"); - expect(localStorage.getItem("deviceAddress")).toBe("10.0.0.50"); - }); - - it("should overwrite existing address", () => { - localStorage.setItem("deviceAddress", "old-address"); - setDeviceAddress("new-address"); - expect(localStorage.getItem("deviceAddress")).toBe("new-address"); - }); - }); - - describe("getWsUrl", () => { - it("should use default port 7497 for simple IPv4 address", () => { - localStorage.setItem("deviceAddress", "192.168.1.100"); - - const url = getWsUrl(); - expect(url).toBe("ws://192.168.1.100:7497/api/v0.1"); - }); - - it("should use custom port when specified", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:8080"); - - const url = getWsUrl(); - expect(url).toBe("ws://192.168.1.100:8080/api/v0.1"); - }); - - it("should handle hostname addresses", () => { - localStorage.setItem("deviceAddress", "mydevice.local"); - - const url = getWsUrl(); - expect(url).toBe("ws://mydevice.local:7497/api/v0.1"); - }); - - it("should handle hostname with custom port", () => { - localStorage.setItem("deviceAddress", "mydevice.local:9000"); - - const url = getWsUrl(); - expect(url).toBe("ws://mydevice.local:9000/api/v0.1"); - }); - - it("should use wss for secure stored URLs", () => { - localStorage.setItem("deviceAddress", "wss://mydevice.local:9000"); - - const url = getWsUrl(); - expect(url).toBe("wss://mydevice.local:9000/api/v0.1"); - }); - - it("should reject invalid port number (out of range)", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:99999"); - - const url = getWsUrl(); - expect(url).toBe(""); - }); - - it("should reject zero port", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:0"); - - const url = getWsUrl(); - expect(url).toBe(""); - }); - - it("should reject negative port", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:-1"); - - const url = getWsUrl(); - expect(url).toBe(""); - }); - - it("should reject non-numeric port", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:abc"); - - const url = getWsUrl(); - expect(url).toBe(""); - }); - - it("should handle unbracketed IPv6 by wrapping in brackets", () => { - localStorage.setItem("deviceAddress", "::1"); - const url = getWsUrl(); - expect(url).toBe("ws://[::1]:7497/api/v0.1"); - }); - - it("should handle bracketed IPv6 with port", () => { - localStorage.setItem("deviceAddress", "[::1]:8080"); - const url = getWsUrl(); - expect(url).toBe("ws://[::1]:8080/api/v0.1"); - }); - - it("should handle common loopback addresses", () => { - localStorage.setItem("deviceAddress", "127.0.0.1"); - - const url = getWsUrl(); - expect(url).toBe("ws://127.0.0.1:7497/api/v0.1"); - }); - - it("should handle port at boundary of valid range", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:65535"); - - const url = getWsUrl(); - expect(url).toBe("ws://192.168.1.100:65535/api/v0.1"); - }); - - it("should handle port 1 (minimum valid)", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:1"); - - const url = getWsUrl(); - expect(url).toBe("ws://192.168.1.100:1/api/v0.1"); - }); - - it("should reject trailing colon", () => { - localStorage.setItem("deviceAddress", "192.168.1.100:"); - const url = getWsUrl(); - expect(url).toBe(""); - }); - - it("should reject invalid IPv4 octets before WebSocket construction", () => { - localStorage.setItem("deviceAddress", "192.168.1.286"); - const url = getWsUrl(); - expect(url).toBe(""); - }); - - it("should reject addresses without a host", () => { - localStorage.setItem("deviceAddress", ":8080"); - const url = getWsUrl(); - expect(url).toBe(""); - }); - }); - - describe("validateDeviceAddress", () => { - it("should trim and normalize valid addresses", () => { - const result = validateDeviceAddress(" 192.168.1.100:8080 "); - expect(result).toEqual({ - ok: true, - address: "192.168.1.100:8080", - host: "192.168.1.100", - port: 8080, - wsUrl: "ws://192.168.1.100:8080/api/v0.1", - }); - }); - - it("should normalize pasted Core API URLs", () => { - const result = validateDeviceAddress( - "http://mydevice.local:9000/api/v0.1", - ); - expect(result).toEqual({ - ok: true, - address: "http://mydevice.local:9000", - host: "mydevice.local", - port: 9000, - wsUrl: "ws://mydevice.local:9000/api/v0.1", - }); - }); - - it("should preserve secure URL schemes", () => { - const result = validateDeviceAddress( - "https://mydevice.local:9000/api/v0.1", - ); - expect(result).toEqual({ - ok: true, - address: "https://mydevice.local:9000", - host: "mydevice.local", - port: 9000, - wsUrl: "wss://mydevice.local:9000/api/v0.1", - }); - }); - - it("should reject malformed IPv6", () => { - const result = validateDeviceAddress("2001:::1"); - expect(result.ok).toBe(false); - }); - - it("should reject URL paths beyond the Core API endpoint", () => { - const result = validateDeviceAddress("http://mydevice.local/other"); - expect(result.ok).toBe(false); - }); - }); -}); diff --git a/src/__tests__/unit/lib/coreApi.validateAddress.test.ts b/src/__tests__/unit/lib/coreApi.validateAddress.test.ts new file mode 100644 index 00000000..3523e51d --- /dev/null +++ b/src/__tests__/unit/lib/coreApi.validateAddress.test.ts @@ -0,0 +1,77 @@ +/** + * Unit Tests: CoreAPI device address validation + * + * This is the entry point for every address the user types, so it owns the + * normalisation rules the settings form depends on. Address parsing itself is + * covered by `devices/endpoint.test.ts`. + */ + +import { describe, it, expect } from "vitest"; +import { validateDeviceAddress } from "@/lib/coreApi"; + +describe("validateDeviceAddress", () => { + it("should trim and normalize valid addresses", () => { + const result = validateDeviceAddress(" 192.168.1.100:8080 "); + + expect(result).toEqual({ + ok: true, + address: "192.168.1.100:8080", + host: "192.168.1.100", + port: 8080, + wsUrl: "ws://192.168.1.100:8080/api/v0.1", + }); + }); + + it("should normalize pasted Core API URLs", () => { + const result = validateDeviceAddress("http://mydevice.local:9000/api/v0.1"); + + expect(result).toEqual({ + ok: true, + address: "mydevice.local:9000", + host: "mydevice.local", + port: 9000, + wsUrl: "ws://mydevice.local:9000/api/v0.1", + }); + }); + + it("should preserve secure URL schemes", () => { + const result = validateDeviceAddress( + "https://mydevice.local:9000/api/v0.1", + ); + + expect(result).toEqual({ + ok: true, + address: "wss://mydevice.local:9000", + host: "mydevice.local", + port: 9000, + wsUrl: "wss://mydevice.local:9000/api/v0.1", + }); + }); + + it("should ask for an address when the field is blank", () => { + const result = validateDeviceAddress(" "); + + expect(result).toMatchObject({ + ok: false, + errorKey: "settings.deviceAddressRequired", + }); + }); + + it("should reject malformed IPv6", () => { + const result = validateDeviceAddress("2001:::1"); + + expect(result).toMatchObject({ + ok: false, + errorKey: "settings.deviceAddressInvalid", + }); + }); + + it("should reject URL paths beyond the Core API endpoint", () => { + const result = validateDeviceAddress("http://mydevice.local/other"); + + expect(result).toMatchObject({ + ok: false, + errorKey: "settings.deviceAddressInvalid", + }); + }); +}); diff --git a/src/__tests__/unit/lib/crypto/credentials.test.ts b/src/__tests__/unit/lib/crypto/credentials.test.ts index 7a480644..00001f73 100644 --- a/src/__tests__/unit/lib/crypto/credentials.test.ts +++ b/src/__tests__/unit/lib/crypto/credentials.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { SecureStorage } from "@aparajita/capacitor-secure-storage"; import { + credentialKeyForRecord, normalizeDeviceKey, SecureCredentialStore, } from "@/lib/crypto/credentials"; @@ -64,26 +65,14 @@ describe("SecureCredentialStore", () => { expect(await store.get("unknown-device")).toBeNull(); }); - it("should migrate credentials from a registered fallback key", async () => { - await store.set("192.168.1.50", creds); - store.registerFallback("mister.local", "192.168.1.50"); - - const migratedResult = await store.get("mister.local"); - - const fresh = new SecureCredentialStore(); - const persistedResult = await fresh.get("mister.local"); - - expect(migratedResult).toEqual(creds); - expect(persistedResult).toEqual(creds); - }); - - it("should prefer credentials already stored under the stable key", async () => { - const stableCreds = { ...creds, clientId: "stable-client" }; - await store.set("192.168.1.50", creds); - await store.set("mister.local", stableCreds); - store.registerFallback("mister.local", "192.168.1.50"); + it("should ignore a stored value that is not a credential", async () => { + // Secure storage returns whatever was written; a half-written entry must + // read as "not paired" rather than reach the handshake with holes in it. + vi.mocked(SecureStorage.get).mockResolvedValueOnce({ + authToken: "token", + } as never); - expect(await store.get("mister.local")).toEqual(stableCreds); + expect(await store.get("192.168.1.50")).toBeNull(); }); it("should remove key on delete", async () => { @@ -142,3 +131,108 @@ describe("SecureCredentialStore", () => { expect(events).toEqual(["remove-resolved", "set-invoked"]); }); }); + +/** + * A record's pairing lives under `record:`, but a device migrated from a + * pre-V2 install still has its only copy under the address it was paired at. + * These cover the one-way trip between the two. + */ +describe("record credentials", () => { + let store: SecureCredentialStore; + + beforeEach(async () => { + store = new SecureCredentialStore(); + await SecureStorage.clear(); + }); + + it("should read the canonical key without consulting the legacy one", async () => { + await store.set(credentialKeyForRecord("record-1"), creds); + await store.set("192.168.1.50", { ...creds, clientId: "stale" }); + + await expect( + store.getForRecord("record-1", "192.168.1.50"), + ).resolves.toEqual({ credentials: creds, legacyKeyUsed: null }); + }); + + it("should fall back to the legacy key and report which one answered", async () => { + await store.set("192.168.1.50", creds); + + await expect( + store.getForRecord("record-1", "192.168.1.50"), + ).resolves.toEqual({ credentials: creds, legacyKeyUsed: "192.168.1.50" }); + }); + + it("should report no pairing when neither key holds one", async () => { + await expect( + store.getForRecord("record-1", "192.168.1.50"), + ).resolves.toEqual({ credentials: null, legacyKeyUsed: null }); + }); + + it("should not rewrite anything on a read", async () => { + await store.set("192.168.1.50", creds); + vi.mocked(SecureStorage.set).mockClear(); + + await store.getForRecord("record-1", "192.168.1.50"); + + expect(SecureStorage.set).not.toHaveBeenCalled(); + }); + + it("should move a proven pairing onto the canonical key", async () => { + await store.set("192.168.1.50", creds); + + await expect( + store.promoteRecordCredentials("record-1", "192.168.1.50"), + ).resolves.toBe(true); + + expect(await store.get(credentialKeyForRecord("record-1"))).toEqual(creds); + expect(await store.get("192.168.1.50")).toBeNull(); + }); + + it("should keep a legacy pairing that never made it across", async () => { + // A write that resolves without persisting would otherwise take the only + // copy of the pairing with it. + await store.set("192.168.1.50", creds); + vi.mocked(SecureStorage.set).mockImplementationOnce(async () => undefined); + + await expect( + store.promoteRecordCredentials("record-1", "192.168.1.50"), + ).resolves.toBe(false); + + expect(await store.get("192.168.1.50")).toEqual(creds); + }); + + it("should leave an existing canonical pairing in place", async () => { + const current = { ...creds, clientId: "current-client" }; + await store.set(credentialKeyForRecord("record-1"), current); + await store.set("192.168.1.50", creds); + + await expect( + store.promoteRecordCredentials("record-1", "192.168.1.50"), + ).resolves.toBe(true); + + expect(await store.get(credentialKeyForRecord("record-1"))).toEqual( + current, + ); + // Another record may still be paired under that address. + expect(await store.get("192.168.1.50")).toEqual(creds); + }); + + it("should settle when there was nothing under the legacy key", async () => { + await expect( + store.promoteRecordCredentials("record-1", "192.168.1.50"), + ).resolves.toBe(true); + }); + + it("should report a storage failure as unsettled", async () => { + await store.set("192.168.1.50", creds); + vi.mocked(SecureStorage.set).mockRejectedValueOnce( + new Error("keychain locked"), + ); + + await expect( + store.promoteRecordCredentials("record-1", "192.168.1.50"), + ).resolves.toBe(false); + + expect(await store.get("192.168.1.50")).toEqual(creds); + }); +}); diff --git a/src/__tests__/unit/lib/devices/deviceRegistry.test.ts b/src/__tests__/unit/lib/devices/deviceRegistry.test.ts new file mode 100644 index 00000000..bf8a7f16 --- /dev/null +++ b/src/__tests__/unit/lib/devices/deviceRegistry.test.ts @@ -0,0 +1,730 @@ +/** + * Unit Tests: device registry + * + * The migration block matters more than the rest put together: it runs exactly + * once per install, it is the only thing standing between an upgrading user and + * having to re-pair every device, and there is no second chance if it is wrong. + */ + +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { Capacitor } from "@capacitor/core"; +import { Preferences } from "@capacitor/preferences"; +import { + __resetDeviceRegistryForTests, + activeAddressOf, + DEVICE_REGISTRY_KEY, + deviceRegistry, + parseDeviceRegistry, + type DeviceRecord, +} from "@/lib/devices/deviceRegistry"; +import { + credentialKeyForRecord, + credentialStore, + normalizeDeviceKey, + type StoredCredentials, +} from "@/lib/crypto/credentials"; + +const creds: StoredCredentials = { + authToken: "token-abc", + pairingKey: "a".repeat(64), + clientId: "client-uuid-1234", + pairedAt: 1700000000000, +}; + +/** The registry blob as it actually sits in storage. */ +async function storedRegistry() { + const stored = await Preferences.get({ key: DEVICE_REGISTRY_KEY }); + return stored.value ? parseDeviceRegistry(JSON.parse(stored.value)) : null; +} + +function records(): DeviceRecord[] { + return Object.values(deviceRegistry.getSnapshot().records); +} + +beforeEach(async () => { + __resetDeviceRegistryForTests(); + localStorage.clear(); + await Preferences.clear(); + vi.clearAllMocks(); + // Native is the case that matters: the web build seeds itself from the page + // origin, which would otherwise add a record to every test below. + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); +}); + +afterEach(() => { + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); +}); + +describe("migrating pre-V2 devices", () => { + it("should import history and the active address into records", async () => { + await Preferences.set({ key: "deviceAddress", value: "steamdeck.local" }); + await Preferences.set({ + key: "deviceHistory", + value: JSON.stringify([ + { + address: "steamdeck.local", + name: "Deck", + nameIsCustom: true, + platform: "linux", + version: "2.5.0", + lastConnectedAt: 123, + }, + { address: "10.0.0.50:8080", name: "Basement" }, + ]), + }); + + await deviceRegistry.hydrate(); + + expect(deviceRegistry.getSnapshot().hydrated).toBe(true); + expect(records()).toHaveLength(2); + expect(deviceRegistry.activeEndpoint()?.address).toBe("steamdeck.local"); + expect(deviceRegistry.activeRecord()).toMatchObject({ + name: "Deck", + nameIsCustom: true, + platform: "linux", + version: "2.5.0", + lastConnectedAt: 123, + legacyCredentialKey: "steamdeck.local", + }); + expect( + records().find((record) => record.name === "Basement"), + ).toMatchObject({ legacyCredentialKey: "10.0.0.50:8080" }); + }); + + it("should keep an imported pairing readable under its new record", async () => { + await credentialStore.set(normalizeDeviceKey("steamdeck.local"), creds); + await Preferences.set({ key: "deviceAddress", value: "steamdeck.local" }); + await Preferences.set({ + key: "deviceHistory", + value: JSON.stringify([{ address: "steamdeck.local" }]), + }); + + await deviceRegistry.hydrate(); + + const record = deviceRegistry.activeRecord()!; + await expect( + credentialStore.getForRecord(record.recordId, record.legacyCredentialKey), + ).resolves.toEqual({ + credentials: creds, + legacyKeyUsed: "steamdeck.local", + }); + }); + + it("should import an IPv6 device under the key it was paired with", async () => { + // Pre-V2 stored the bare form; the registry canonicalises to brackets, so + // the credential key has to stay in the old shape or the pairing is lost. + await credentialStore.set("::1", creds); + await Preferences.set({ key: "deviceAddress", value: "::1" }); + + await deviceRegistry.hydrate(); + + const record = deviceRegistry.activeRecord()!; + expect(deviceRegistry.activeEndpoint()?.address).toBe("[::1]"); + expect(record.legacyCredentialKey).toBe("::1"); + await expect( + credentialStore.getForRecord(record.recordId, record.legacyCredentialKey), + ).resolves.toMatchObject({ credentials: creds }); + }); + + it("should read the active address from localStorage when Preferences has none", async () => { + localStorage.setItem("deviceAddress", "10.0.0.206"); + + await deviceRegistry.hydrate(); + + expect(deviceRegistry.activeEndpoint()?.address).toBe("10.0.0.206"); + }); + + it("should delete the legacy keys once they are imported", async () => { + localStorage.setItem("deviceAddress", "steamdeck.local"); + await Preferences.set({ key: "deviceAddress", value: "steamdeck.local" }); + await Preferences.set({ + key: "deviceHistory", + value: JSON.stringify([{ address: "steamdeck.local", name: "Deck" }]), + }); + + await deviceRegistry.hydrate(); + + expect(localStorage.getItem("deviceAddress")).toBeNull(); + expect((await Preferences.get({ key: "deviceAddress" })).value).toBeNull(); + expect((await Preferences.get({ key: "deviceHistory" })).value).toBeNull(); + // The imported state survives the cleanup. + expect(deviceRegistry.activeEndpoint()?.address).toBe("steamdeck.local"); + expect((await storedRegistry())?.records).toBeDefined(); + }); + + it("should keep the entries that parse when others are corrupt", async () => { + await Preferences.set({ key: "deviceAddress", value: "10.0.0.206" }); + await Preferences.set({ + key: "deviceHistory", + value: JSON.stringify([ + { address: "10.0.0.206", name: "Good" }, + { address: "999.999.999.999" }, + { name: "no address at all" }, + null, + "not an object", + ]), + }); + + await deviceRegistry.hydrate(); + + expect(records()).toHaveLength(1); + expect(deviceRegistry.getSnapshot()).toMatchObject({ + hydrated: true, + hydrationError: null, + }); + expect(deviceRegistry.activeRecord()).toMatchObject({ name: "Good" }); + }); + + it("should survive a deviceHistory that is not valid JSON", async () => { + await Preferences.set({ key: "deviceAddress", value: "10.0.0.206" }); + await Preferences.set({ key: "deviceHistory", value: "{not json" }); + + await deviceRegistry.hydrate(); + + expect(deviceRegistry.getSnapshot().hydrationError).toBeNull(); + expect(deviceRegistry.activeEndpoint()?.address).toBe("10.0.0.206"); + }); + + it("should create a record for an active address missing from history", async () => { + await Preferences.set({ key: "deviceAddress", value: "10.0.0.206" }); + await Preferences.set({ + key: "deviceHistory", + value: JSON.stringify([{ address: "10.0.0.1" }]), + }); + + await deviceRegistry.hydrate(); + + expect(records()).toHaveLength(2); + expect(deviceRegistry.activeEndpoint()?.address).toBe("10.0.0.206"); + }); + + it("should merge duplicate history entries for one address", async () => { + await Preferences.set({ + key: "deviceHistory", + value: JSON.stringify([ + { address: "10.0.0.206", name: "Deck" }, + { address: "ws://10.0.0.206:7497/api/v0.1", platform: "linux" }, + ]), + }); + + await deviceRegistry.hydrate(); + + expect(records()).toHaveLength(1); + expect(records()[0]).toMatchObject({ name: "Deck", platform: "linux" }); + }); + + it("should leave the active record unset when there was no active address", async () => { + await Preferences.set({ + key: "deviceHistory", + value: JSON.stringify([{ address: "10.0.0.206" }]), + }); + + await deviceRegistry.hydrate(); + + expect(records()).toHaveLength(1); + expect(deviceRegistry.getSnapshot().activeRecordId).toBeNull(); + }); + + it("should not re-import once the legacy keys are gone", async () => { + await Preferences.set({ key: "deviceAddress", value: "steamdeck.local" }); + await Preferences.set({ + key: "deviceHistory", + value: JSON.stringify([{ address: "steamdeck.local", name: "Deck" }]), + }); + await deviceRegistry.hydrate(); + const firstRecordId = deviceRegistry.getSnapshot().activeRecordId; + expect(firstRecordId).not.toBeNull(); + + __resetDeviceRegistryForTests(); + await deviceRegistry.hydrate(); + + const resumed = deviceRegistry.getSnapshot(); + expect(resumed.activeRecordId).toBe(firstRecordId); + expect(Object.keys(resumed.records)).toEqual([firstRecordId]); + }); + + it("should not touch storage on a first run with nothing to migrate", async () => { + await deviceRegistry.hydrate(); + + expect(Preferences.remove).not.toHaveBeenCalled(); + expect(records()).toHaveLength(0); + }); +}); + +describe("seeding a browser session", () => { + it("should adopt the page origin when nothing is stored", async () => { + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); + + await deviceRegistry.hydrate(); + + expect(deviceRegistry.activeEndpoint()?.host).toBe(location.hostname); + expect(records()).toHaveLength(1); + }); + + it("should prefer imported devices over the page origin", async () => { + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); + await Preferences.set({ key: "deviceAddress", value: "10.0.0.206" }); + + await deviceRegistry.hydrate(); + + expect(records()).toHaveLength(1); + expect(deviceRegistry.activeEndpoint()?.address).toBe("10.0.0.206"); + }); +}); + +describe("reading a stored registry", () => { + it("should refuse to overwrite an unreadable stored registry", async () => { + await Preferences.set({ + key: DEVICE_REGISTRY_KEY, + value: JSON.stringify({ schemaVersion: 2, records: "broken" }), + }); + vi.clearAllMocks(); + + await deviceRegistry.hydrate(); + + expect(deviceRegistry.getSnapshot()).toMatchObject({ + hydrated: false, + hydrationError: "registry_unreadable", + }); + expect(Preferences.set).not.toHaveBeenCalled(); + expect(Preferences.remove).not.toHaveBeenCalled(); + }); + + it("should stay unhydrated when storage cannot be read", async () => { + vi.mocked(Preferences.get).mockRejectedValueOnce( + new Error("storage offline"), + ); + + await deviceRegistry.hydrate(); + + expect(deviceRegistry.getSnapshot()).toMatchObject({ + hydrated: false, + hydrationError: "hydrate_failed", + }); + }); + + it("should refuse to persist devices while the read is failing", async () => { + vi.mocked(Preferences.get).mockRejectedValueOnce( + new Error("storage offline"), + ); + + await expect(deviceRegistry.selectAddress("10.0.0.206")).rejects.toThrow( + /refusing to overwrite/i, + ); + + expect(Preferences.set).not.toHaveBeenCalledWith( + expect.objectContaining({ key: DEVICE_REGISTRY_KEY }), + ); + }); + + it("should recover once storage reads succeed again", async () => { + vi.mocked(Preferences.get).mockRejectedValueOnce( + new Error("storage offline"), + ); + await deviceRegistry.hydrate(); + expect(deviceRegistry.getSnapshot().hydrated).toBe(false); + + const record = await deviceRegistry.selectAddress("10.0.0.206"); + + expect(record).not.toBeNull(); + expect(deviceRegistry.getSnapshot().hydrated).toBe(true); + expect(deviceRegistry.activeEndpoint()?.address).toBe("10.0.0.206"); + }); + + it("should keep readable records when one stored record is unreadable", async () => { + const kept = await deviceRegistry.selectAddress("10.0.0.206"); + const dropped = await deviceRegistry.selectAddress("10.0.0.207"); + const stored = await Preferences.get({ key: DEVICE_REGISTRY_KEY }); + const registry = JSON.parse(stored.value!) as { + activeRecordId: string; + records: Record; + }; + expect(registry.activeRecordId).toBe(dropped!.recordId); + registry.records[dropped!.recordId] = { + ...registry.records[dropped!.recordId]!, + endpoints: [], + }; + await Preferences.set({ + key: DEVICE_REGISTRY_KEY, + value: JSON.stringify(registry), + }); + + __resetDeviceRegistryForTests(); + await deviceRegistry.hydrate(); + + const snapshot = deviceRegistry.getSnapshot(); + expect(snapshot.hydrated).toBe(true); + expect(Object.keys(snapshot.records)).toEqual([kept!.recordId]); + expect(snapshot.activeRecordId).toBeNull(); + }); + + it("should reject a registry written by a newer schema", () => { + expect(parseDeviceRegistry({ schemaVersion: 3, records: {} })).toBeNull(); + }); +}); + +describe("selecting a device by address", () => { + it("should reuse the record for an equivalent endpoint", async () => { + const first = await deviceRegistry.selectAddress("HTTP://STEAMDECK.local"); + const second = await deviceRegistry.selectAddress( + "ws://steamdeck.local:7497/api/v0.1", + ); + + expect(second?.recordId).toBe(first?.recordId); + expect(records()).toHaveLength(1); + }); + + it("should keep a non-default port on the selected endpoint", async () => { + await deviceRegistry.selectAddress("192.168.1.100:8080"); + + expect(deviceRegistry.activeEndpoint()?.address).toBe("192.168.1.100:8080"); + }); + + it("should key a new record on the address the user typed", async () => { + const record = await deviceRegistry.selectAddress("STEAMDECK.local:7497"); + + expect(record?.legacyCredentialKey).toBe("steamdeck.local"); + }); + + it("should ignore an address it cannot parse", async () => { + expect(await deviceRegistry.selectAddress("not a host")).toBeNull(); + expect(records()).toHaveLength(0); + }); + + it("should clear the active selection without discarding the record", async () => { + const record = await deviceRegistry.selectAddress("192.168.1.100"); + + await deviceRegistry.setActiveRecord(null); + + const snapshot = deviceRegistry.getSnapshot(); + expect(snapshot.activeRecordId).toBeNull(); + expect(snapshot.records[record!.recordId]).toBeDefined(); + }); +}); + +describe("the active address every screen displays", () => { + const address = () => activeAddressOf(deviceRegistry.getSnapshot()); + + it("should be empty before the registry hydrates", () => { + expect(address()).toBe(""); + }); + + it("should be the active record's address", async () => { + await deviceRegistry.selectAddress("192.168.1.100"); + + expect(address()).toBe("192.168.1.100"); + }); + + it("should keep a port the user typed", async () => { + await deviceRegistry.selectAddress("192.168.1.100:8080"); + + expect(address()).toBe("192.168.1.100:8080"); + }); + + it("should follow the active record when the user switches devices", async () => { + await deviceRegistry.selectAddress("192.168.1.10"); + const second = await deviceRegistry.selectAddress("192.168.1.11"); + + await deviceRegistry.setActiveRecord(second!.recordId); + + expect(address()).toBe("192.168.1.11"); + }); + + it("should be empty when no record is active", async () => { + await deviceRegistry.selectAddress("192.168.1.100"); + + await deviceRegistry.setActiveRecord(null); + + expect(address()).toBe(""); + }); +}); + +describe("forgetting a device", () => { + it("should drop the record, its selection, and its pairing", async () => { + const kept = await deviceRegistry.selectAddress("10.0.0.1"); + const target = await deviceRegistry.selectAddress("10.0.0.2"); + await credentialStore.set(credentialKeyForRecord(target!.recordId), creds); + await credentialStore.set("10.0.0.2", creds); + + const removed = await deviceRegistry.removeRecord(target!.recordId); + + expect(removed?.recordId).toBe(target!.recordId); + const snapshot = deviceRegistry.getSnapshot(); + expect(snapshot.activeRecordId).toBeNull(); + expect(Object.keys(snapshot.records)).toEqual([kept!.recordId]); + await expect( + credentialStore.get(credentialKeyForRecord(target!.recordId)), + ).resolves.toBeNull(); + await expect(credentialStore.get("10.0.0.2")).resolves.toBeNull(); + }); + + it("should keep a legacy pairing another record still claims", async () => { + const typed = await deviceRegistry.selectAddress("10.0.0.206"); + const discovered = await deviceRegistry.selectDiscovered({ + discoveryId: "device-a", + hostname: "steamdeck.local", + addresses: ["10.0.0.206"], + port: 7497, + }); + expect(discovered?.legacyCredentialKey).toBe("10.0.0.206"); + await credentialStore.set("10.0.0.206", creds); + + await deviceRegistry.removeRecord(discovered!.recordId); + + // `typed` is still paired under that key and was not the record forgotten. + expect(deviceRegistry.getSnapshot().records[typed!.recordId]).toBeDefined(); + await expect(credentialStore.get("10.0.0.206")).resolves.toEqual(creds); + }); + + it("should ignore removal of an unknown record", async () => { + await deviceRegistry.selectAddress("10.0.0.1"); + vi.clearAllMocks(); + + expect(await deviceRegistry.removeRecord("no-such-record")).toBeNull(); + expect(Preferences.set).not.toHaveBeenCalled(); + }); +}); + +describe("selecting a discovered device", () => { + const announcement = { + discoveryId: "core-id", + hostname: "steamdeck.local", + addresses: ["10.0.0.206"], + port: 7497, + name: "Deck", + platform: "linux", + }; + + it("should connect by hostname but key credentials on the announced address", async () => { + const record = await deviceRegistry.selectDiscovered(announcement); + + expect(record).toMatchObject({ + discoveryId: "core-id", + name: "Deck", + platform: "linux", + // Pre-V2 saved a scanned device by IP even when it advertised a hostname. + legacyCredentialKey: "10.0.0.206", + }); + expect(deviceRegistry.activeEndpoint()?.address).toBe("steamdeck.local"); + }); + + it("should follow a device that changed address, matching on discovery ID", async () => { + const first = await deviceRegistry.selectDiscovered(announcement); + const second = await deviceRegistry.selectDiscovered({ + ...announcement, + discoveryId: "CORE-ID", + hostname: "deck-new.local", + addresses: ["10.0.0.207"], + name: "Deck Renamed", + }); + + expect(second?.recordId).toBe(first?.recordId); + expect(records()).toHaveLength(1); + expect(second).toMatchObject({ name: "Deck Renamed" }); + expect(second?.endpoints).toHaveLength(2); + expect(deviceRegistry.activeEndpoint()?.address).toBe("deck-new.local"); + }); + + it("should not merge discovered devices solely by shared endpoint", async () => { + const first = await deviceRegistry.selectDiscovered({ + ...announcement, + discoveryId: "device-a", + }); + const second = await deviceRegistry.selectDiscovered({ + ...announcement, + discoveryId: "device-b", + }); + + expect(second?.recordId).not.toBe(first?.recordId); + expect(records()).toHaveLength(2); + }); + + it("should not rewrite the record when the same announcement repeats", async () => { + await deviceRegistry.selectDiscovered(announcement); + vi.clearAllMocks(); + + await deviceRegistry.selectDiscovered(announcement); + + expect(Preferences.set).not.toHaveBeenCalled(); + expect(records()).toHaveLength(1); + }); + + it("should keep a custom name when a later announcement carries a new one", async () => { + const discovered = await deviceRegistry.selectDiscovered(announcement); + await deviceRegistry.setCustomName(discovered!.recordId, "Living Room"); + + const rediscovered = await deviceRegistry.selectDiscovered({ + ...announcement, + name: "Deck Renamed", + version: "2.5.0", + }); + + expect(rediscovered).toMatchObject({ + name: "Living Room", + nameIsCustom: true, + version: "2.5.0", + }); + }); + + it("should adopt the announced name again once the custom name is cleared", async () => { + const discovered = await deviceRegistry.selectDiscovered(announcement); + await deviceRegistry.setCustomName(discovered!.recordId, "Living Room"); + + await deviceRegistry.setCustomName(discovered!.recordId, " "); + const rediscovered = await deviceRegistry.selectDiscovered(announcement); + + expect(rediscovered).toMatchObject({ name: "Deck", nameIsCustom: false }); + }); + + it("should fall back to an announced address when there is no hostname", async () => { + const record = await deviceRegistry.selectDiscovered({ + addresses: ["10.0.0.206"], + port: 8080, + }); + + expect(record).not.toBeNull(); + expect(deviceRegistry.activeEndpoint()?.address).toBe("10.0.0.206:8080"); + }); + + it("should ignore an announcement with nowhere to connect", async () => { + expect( + await deviceRegistry.selectDiscovered({ addresses: [], port: 7497 }), + ).toBeNull(); + }); +}); + +describe("recording a successful connection", () => { + it("should absorb the duplicate a proven credential key identifies", async () => { + const migrated = await deviceRegistry.selectAddress("10.0.0.206"); + const discovered = await deviceRegistry.selectDiscovered({ + discoveryId: "device-a", + hostname: "steamdeck.local", + addresses: ["10.0.0.206"], + port: 7497, + }); + expect(records()).toHaveLength(2); + + await deviceRegistry.markConnected(discovered!.recordId, { + provenLegacyKey: "10.0.0.206", + }); + + const snapshot = deviceRegistry.getSnapshot(); + expect(Object.keys(snapshot.records)).toEqual([discovered!.recordId]); + expect(snapshot.activeRecordId).toBe(discovered!.recordId); + expect(snapshot.records[discovered!.recordId]).toMatchObject({ + endpoints: expect.arrayContaining([ + expect.objectContaining({ host: "steamdeck.local" }), + expect.objectContaining({ host: "10.0.0.206" }), + ]), + }); + expect(snapshot.records[migrated!.recordId]).toBeUndefined(); + }); + + it("should leave both records alone when no key was proven", async () => { + await deviceRegistry.selectAddress("10.0.0.206"); + const discovered = await deviceRegistry.selectDiscovered({ + discoveryId: "device-a", + hostname: "steamdeck.local", + addresses: ["10.0.0.206"], + port: 7497, + }); + + await deviceRegistry.markConnected(discovered!.recordId); + + expect(records()).toHaveLength(2); + }); + + it("should drop the legacy key once the migration is settled", async () => { + const record = await deviceRegistry.selectAddress("10.0.0.206"); + + await deviceRegistry.markConnected(record!.recordId, { + migrationSettled: true, + }); + + const updated = deviceRegistry.getSnapshot().records[record!.recordId]; + expect(updated?.legacyCredentialKey).toBeUndefined(); + expect(updated?.lastConnectedAt).toEqual(expect.any(Number)); + }); + + it("should keep the legacy key while the migration is outstanding", async () => { + const record = await deviceRegistry.selectAddress("10.0.0.206"); + + await deviceRegistry.markConnected(record!.recordId); + + expect( + deviceRegistry.getSnapshot().records[record!.recordId], + ).toMatchObject({ legacyCredentialKey: "10.0.0.206" }); + }); + + it("should ignore a connection for an unknown record", async () => { + await deviceRegistry.selectAddress("10.0.0.206"); + vi.clearAllMocks(); + + await deviceRegistry.markConnected("no-such-record"); + + expect(Preferences.set).not.toHaveBeenCalled(); + }); +}); + +describe("applying device-reported metadata", () => { + it("should not overwrite a name the user chose", async () => { + const record = await deviceRegistry.selectAddress("10.0.0.206"); + await deviceRegistry.setCustomName(record!.recordId, "Living Room"); + + await deviceRegistry.applyDiscoveredMetadata(record!.recordId, { + name: "Deck", + platform: "linux", + }); + + expect( + deviceRegistry.getSnapshot().records[record!.recordId], + ).toMatchObject({ name: "Living Room", platform: "linux" }); + }); + + it("should not write when the metadata says nothing new", async () => { + const record = await deviceRegistry.selectAddress("10.0.0.206"); + await deviceRegistry.applyDiscoveredMetadata(record!.recordId, { + name: "Deck", + }); + vi.clearAllMocks(); + + await deviceRegistry.applyDiscoveredMetadata(record!.recordId, { + name: "Deck", + platform: "", + }); + + expect(Preferences.set).not.toHaveBeenCalled(); + }); +}); + +describe("persistence", () => { + it("should serialize writes in mutation order", async () => { + await deviceRegistry.hydrate(); + const writes: Array<{ value: string; resolve: () => void }> = []; + vi.mocked(Preferences.set).mockImplementation( + ({ value }: { key: string; value: string }) => + new Promise((resolve) => { + writes.push({ value, resolve }); + }), + ); + + const first = deviceRegistry.selectAddress("10.0.0.1"); + await vi.waitFor(() => expect(writes).toHaveLength(1)); + const second = deviceRegistry.selectAddress("10.0.0.2"); + + expect(writes).toHaveLength(1); + writes[0]!.resolve(); + await vi.waitFor(() => expect(writes).toHaveLength(2)); + writes[1]!.resolve(); + await Promise.all([first, second]); + + const firstRegistry = parseDeviceRegistry(JSON.parse(writes[0]!.value)); + const secondRegistry = parseDeviceRegistry(JSON.parse(writes[1]!.value)); + expect(Object.values(firstRegistry?.records ?? {})).toHaveLength(1); + expect(Object.values(secondRegistry?.records ?? {})).toHaveLength(2); + expect( + secondRegistry?.records[secondRegistry.activeRecordId ?? ""]?.endpoints[0] + ?.host, + ).toBe("10.0.0.2"); + }); +}); diff --git a/src/__tests__/unit/lib/devices/endpoint.test.ts b/src/__tests__/unit/lib/devices/endpoint.test.ts new file mode 100644 index 00000000..1ed39392 --- /dev/null +++ b/src/__tests__/unit/lib/devices/endpoint.test.ts @@ -0,0 +1,172 @@ +/** + * Unit Tests: device endpoint parsing + * + * Every stored endpoint and every address the user types goes through + * `parseDeviceEndpoint`, so the two can never disagree about what a host means. + * That makes this the one place host/port validation is exercised. + */ + +import { describe, it, expect } from "vitest"; +import { + DEFAULT_DEVICE_PORT, + formatDeviceEndpoint, + isValidHost, + parseDeviceEndpoint, +} from "@/lib/devices/endpoint"; + +function endpointOf(input: string) { + const result = parseDeviceEndpoint(input); + if (!result.ok) throw new Error(`expected "${input}" to parse`); + return result.endpoint; +} + +describe("parseDeviceEndpoint", () => { + describe("hosts and ports", () => { + it("should default the port when none is given", () => { + expect(endpointOf("192.168.1.100")).toMatchObject({ + host: "192.168.1.100", + port: DEFAULT_DEVICE_PORT, + address: "192.168.1.100", + wsUrl: "ws://192.168.1.100:7497/api/v0.1", + }); + }); + + it("should keep an explicit port in the display address", () => { + expect(endpointOf("192.168.1.100:8080")).toMatchObject({ + port: 8080, + address: "192.168.1.100:8080", + wsUrl: "ws://192.168.1.100:8080/api/v0.1", + }); + }); + + it("should accept hostnames", () => { + expect(endpointOf("MyDevice.local")).toMatchObject({ + host: "mydevice.local", + port: DEFAULT_DEVICE_PORT, + }); + }); + + it("should accept the port range boundaries", () => { + expect(endpointOf("192.168.1.100:1").port).toBe(1); + expect(endpointOf("192.168.1.100:65535").port).toBe(65535); + }); + + it("should trim surrounding whitespace", () => { + expect(endpointOf(" 192.168.1.100:8080 ").address).toBe( + "192.168.1.100:8080", + ); + }); + + it.each([ + ["a port above the valid range", "192.168.1.100:99999"], + ["a zero port", "192.168.1.100:0"], + ["a negative port", "192.168.1.100:-1"], + ["a non-numeric port", "192.168.1.100:abc"], + ["a trailing colon", "192.168.1.100:"], + ["an out-of-range IPv4 octet", "192.168.1.286"], + ["a missing host", ":8080"], + ["an empty string", ""], + ["embedded whitespace", "192.168.1.100 :8080"], + // Multiple colons without hex segments is not IPv6, so it must be + // rejected rather than silently bracketed. + ["multiple colons that are not IPv6", "my:host:name"], + ])("should reject %s", (_label, input) => { + expect(parseDeviceEndpoint(input).ok).toBe(false); + }); + }); + + describe("IPv6", () => { + it("should bracket an unbracketed address", () => { + expect(endpointOf("::1")).toMatchObject({ + host: "::1", + port: DEFAULT_DEVICE_PORT, + address: "[::1]", + wsUrl: "ws://[::1]:7497/api/v0.1", + }); + }); + + it("should accept a bracketed address with a port", () => { + expect(endpointOf("[::1]:8080")).toMatchObject({ + host: "::1", + port: 8080, + wsUrl: "ws://[::1]:8080/api/v0.1", + }); + }); + + it("should reject malformed IPv6", () => { + expect(parseDeviceEndpoint("2001:::1").ok).toBe(false); + }); + }); + + describe("pasted URLs", () => { + it("should accept a Core API URL and drop its path", () => { + expect(endpointOf("http://mydevice.local:9000/api/v0.1")).toMatchObject({ + scheme: "ws", + host: "mydevice.local", + port: 9000, + address: "mydevice.local:9000", + }); + }); + + it("should map https and wss to a secure socket", () => { + expect(endpointOf("https://mydevice.local:9000")).toMatchObject({ + scheme: "wss", + address: "wss://mydevice.local:9000", + wsUrl: "wss://mydevice.local:9000/api/v0.1", + }); + expect(endpointOf("wss://mydevice.local:9000").scheme).toBe("wss"); + }); + + it.each([ + ["an unsupported scheme", "ftp://mydevice.local"], + ["a path beyond the Core API endpoint", "http://mydevice.local/other"], + ["a query string", "http://mydevice.local/?x=1"], + ["a fragment", "http://mydevice.local/#x"], + ["embedded credentials", "http://user:pass@mydevice.local"], + ])("should reject %s", (_label, input) => { + expect(parseDeviceEndpoint(input).ok).toBe(false); + }); + }); + + it("should produce a stable endpoint id for equivalent inputs", () => { + const bare = endpointOf("MyDevice.local"); + const explicit = endpointOf("mydevice.local:7497"); + const url = endpointOf("ws://mydevice.local:7497/api/v0.1"); + + expect(explicit.endpointId).toBe(bare.endpointId); + expect(url.endpointId).toBe(bare.endpointId); + }); +}); + +describe("formatDeviceEndpoint", () => { + it("should hide the default port from the display address", () => { + expect( + formatDeviceEndpoint("192.168.1.100", DEFAULT_DEVICE_PORT), + ).toMatchObject({ + address: "192.168.1.100", + endpointId: "ws://192.168.1.100:7497", + }); + }); + + it("should lowercase the host", () => { + expect(formatDeviceEndpoint("MyDevice.Local", 7497).host).toBe( + "mydevice.local", + ); + }); +}); + +describe("isValidHost", () => { + it.each(["192.168.1.100", "127.0.0.1", "::1", "mydevice.local", "core"])( + "should accept %s", + (host) => { + expect(isValidHost(host)).toBe(true); + }, + ); + + it.each(["", "192.168.1.286", "-leading.local", "trailing-.local", "a..b"])( + "should reject %s", + (host) => { + expect(isValidHost(host)).toBe(false); + }, + ); +}); diff --git a/src/__tests__/unit/lib/libraryImageCache.test.ts b/src/__tests__/unit/lib/libraryImageCache.test.ts index 7ac573cd..5e1000b0 100644 --- a/src/__tests__/unit/lib/libraryImageCache.test.ts +++ b/src/__tests__/unit/lib/libraryImageCache.test.ts @@ -166,7 +166,7 @@ describe("Library image disk cache", () => { systemId: "SNES", }; const options = { - targetDeviceAddress: "device-a", + deviceKey: "device-a", maxSize: 128, priority: "thumbnail" as const, }; diff --git a/src/__tests__/unit/lib/libraryImages.test.ts b/src/__tests__/unit/lib/libraryImages.test.ts index 36ac223c..ff1f5b60 100644 --- a/src/__tests__/unit/lib/libraryImages.test.ts +++ b/src/__tests__/unit/lib/libraryImages.test.ts @@ -23,12 +23,12 @@ function requestLibraryImage( fallbackSystemId: string, options: Omit< Parameters[2], - "targetDeviceAddress" + "deviceKey" >, ) { return requestLibraryImageForDevice(media, fallbackSystemId, { ...options, - targetDeviceAddress: "device-a", + deviceKey: "device-a", }); } diff --git a/src/__tests__/unit/lib/storage.test.ts b/src/__tests__/unit/lib/storage.test.ts deleted file mode 100644 index 48050ea1..00000000 --- a/src/__tests__/unit/lib/storage.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { getDeviceAddress, setDeviceAddress } from "../../../lib/coreApi"; -import { Capacitor } from "@capacitor/core"; - -describe("Device Address Storage", () => { - let originalLocalStorage: Storage; - let originalLocation: Location; - - beforeEach(() => { - vi.clearAllMocks(); - - // Store original values - originalLocalStorage = window.localStorage; - originalLocation = window.location; - - // Create fresh localStorage mock for each test - const localStorageMock = { - getItem: vi.fn((key: string) => localStorageMock._store[key] || null), - setItem: vi.fn((key: string, value: string) => { - localStorageMock._store[key] = value; - }), - clear: vi.fn(() => { - localStorageMock._store = {}; - }), - _store: {} as { [key: string]: string }, - }; - - Object.defineProperty(window, "localStorage", { - value: localStorageMock, - writable: true, - configurable: true, - }); - - // Reset window.location for each test to avoid interference - Object.defineProperty(window, "location", { - value: { hostname: "localhost" }, - writable: true, - configurable: true, - }); - - // Clear the mock store - localStorageMock.clear(); - }); - - afterEach(() => { - // Restore original values - Object.defineProperty(window, "localStorage", { - value: originalLocalStorage, - writable: true, - configurable: true, - }); - Object.defineProperty(window, "location", { - value: originalLocation, - writable: true, - configurable: true, - }); - }); - - it("should handle device address retrieval when no address is stored", () => { - vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); - localStorage.clear(); // Ensure no stored address - - const address = getDeviceAddress(); - - // On native platform with no stored address, returns empty string - // (user must explicitly set a device address) - expect(address).toBe(""); - }); - - it("should use window.location.hostname on web platform when no address stored", () => { - vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); - localStorage.clear(); // Ensure localStorage is empty - Object.defineProperty(window, "location", { - value: { hostname: "localhost" }, - writable: true, - configurable: true, - }); - - const address = getDeviceAddress(); - - expect(address).toBe("localhost"); - }); - - it("should save device address to localStorage", () => { - setDeviceAddress("192.168.1.100"); - - expect(localStorage.getItem("deviceAddress")).toBe("192.168.1.100"); - - // Note: Preferences.set is also called but due to test environment mocking complexities, - // we don't test it here. The important behavior is localStorage persistence. - }); - - it("should retrieve stored device address from localStorage", () => { - vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); - // Clear first, then set the specific value we want to test - localStorage.clear(); - localStorage.setItem("deviceAddress", "192.168.1.50"); - - const address = getDeviceAddress(); - - expect(address).toBe("192.168.1.50"); - }); -}); diff --git a/src/__tests__/unit/lib/store.test.ts b/src/__tests__/unit/lib/store.test.ts index c006df47..46107abd 100644 --- a/src/__tests__/unit/lib/store.test.ts +++ b/src/__tests__/unit/lib/store.test.ts @@ -9,7 +9,6 @@ describe("StatusStore", () => { connected: false, connectionError: "", connectionState: ConnectionState.IDLE, - deviceHistory: [], playing: { systemId: "", systemName: "", @@ -29,160 +28,6 @@ describe("StatusStore", () => { }); }); - describe("device history business logic", () => { - it("should deduplicate devices when adding same address twice", () => { - const { addDeviceHistory } = useStatusStore.getState(); - - addDeviceHistory("192.168.1.100"); - addDeviceHistory("192.168.1.100"); // Add same address again - - // Should only have one entry - expect(useStatusStore.getState().deviceHistory).toEqual([ - { address: "192.168.1.100" }, - ]); - }); - - it("should add, remove, and clear device history", () => { - const { addDeviceHistory, removeDeviceHistory, clearDeviceHistory } = - useStatusStore.getState(); - - // Add devices - addDeviceHistory("192.168.1.100"); - addDeviceHistory("192.168.1.200"); - expect(useStatusStore.getState().deviceHistory).toHaveLength(2); - - // Remove a device - removeDeviceHistory("192.168.1.100"); - expect(useStatusStore.getState().deviceHistory).toEqual([ - { address: "192.168.1.200" }, - ]); - - // Clear all devices - clearDeviceHistory(); - expect(useStatusStore.getState().deviceHistory).toEqual([]); - }); - }); - - describe("updateDeviceHistoryMeta", () => { - const seedEntry = (overrides: Record = {}) => { - useStatusStore.setState({ - deviceHistory: [{ address: "192.168.1.100", ...overrides }], - }); - }; - - it("is a no-op when the address does not exist", () => { - useStatusStore.setState({ deviceHistory: [] }); - useStatusStore - .getState() - .updateDeviceHistoryMeta("ghost", { name: "Nope" }); - expect(useStatusStore.getState().deviceHistory).toEqual([]); - }); - - describe("source: auto (default)", () => { - it("merges platform, version, and lastConnectedAt", () => { - seedEntry(); - useStatusStore.getState().updateDeviceHistoryMeta("192.168.1.100", { - platform: "linux", - version: "1.2.3", - lastConnectedAt: 12345, - }); - const entry = useStatusStore.getState().deviceHistory[0]!; - expect(entry.platform).toBe("linux"); - expect(entry.version).toBe("1.2.3"); - expect(entry.lastConnectedAt).toBe(12345); - }); - - it("sets name when no custom name is set", () => { - seedEntry(); - useStatusStore - .getState() - .updateDeviceHistoryMeta("192.168.1.100", { name: "Office" }); - expect(useStatusStore.getState().deviceHistory[0]!.name).toBe("Office"); - }); - - it("preserves a custom name (nameIsCustom=true)", () => { - seedEntry({ name: "My Pixel", nameIsCustom: true }); - useStatusStore - .getState() - .updateDeviceHistoryMeta("192.168.1.100", { name: "Auto Name" }); - expect(useStatusStore.getState().deviceHistory[0]!.name).toBe( - "My Pixel", - ); - }); - - it("ignores an empty-string name without overwriting an existing one", () => { - seedEntry({ name: "Living Room" }); - useStatusStore - .getState() - .updateDeviceHistoryMeta("192.168.1.100", { name: "" }); - expect(useStatusStore.getState().deviceHistory[0]!.name).toBe( - "Living Room", - ); - }); - }); - - describe("source: manual", () => { - it("sets a name and marks nameIsCustom", () => { - seedEntry(); - useStatusStore - .getState() - .updateDeviceHistoryMeta( - "192.168.1.100", - { name: "Bedroom" }, - { source: "manual" }, - ); - const entry = useStatusStore.getState().deviceHistory[0]!; - expect(entry.name).toBe("Bedroom"); - expect(entry.nameIsCustom).toBe(true); - }); - - it("clears the custom name when name is empty string", () => { - seedEntry({ name: "Bedroom", nameIsCustom: true }); - useStatusStore - .getState() - .updateDeviceHistoryMeta( - "192.168.1.100", - { name: "" }, - { source: "manual" }, - ); - const entry = useStatusStore.getState().deviceHistory[0]!; - expect(entry.name).toBeUndefined(); - expect(entry.nameIsCustom).toBe(false); - }); - - it("clears the custom name when name is undefined and present in meta", () => { - seedEntry({ name: "Bedroom", nameIsCustom: true }); - useStatusStore - .getState() - .updateDeviceHistoryMeta( - "192.168.1.100", - { name: undefined }, - { source: "manual" }, - ); - const entry = useStatusStore.getState().deviceHistory[0]!; - expect(entry.name).toBeUndefined(); - expect(entry.nameIsCustom).toBe(false); - }); - - it("merges platform/version/lastConnectedAt without touching name when name is absent", () => { - seedEntry({ name: "Existing", nameIsCustom: true }); - useStatusStore - .getState() - .updateDeviceHistoryMeta( - "192.168.1.100", - { platform: "darwin", version: "2.0.0", lastConnectedAt: 99 }, - { source: "manual" }, - ); - const entry = useStatusStore.getState().deviceHistory[0]!; - expect(entry.name).toBe("Existing"); - expect(entry.nameIsCustom).toBe(true); - expect(entry.platform).toBe("darwin"); - expect(entry.version).toBe("2.0.0"); - expect(entry.lastConnectedAt).toBe(99); - }); - }); - }); - describe("ConnectionState", () => { it("should derive connected boolean from connectionState (backward compatibility)", () => { const { setConnectionState } = useStatusStore.getState(); @@ -414,7 +259,6 @@ describe("StatusStore", () => { // Set some non-connection state store.setCameraOpen(true); - store.addDeviceHistory("192.168.1.100"); store.setLoggedInUser({ uid: "test-user" } as any); // Reset connection state @@ -423,7 +267,6 @@ describe("StatusStore", () => { // Verify non-connection state is preserved const state = useStatusStore.getState(); expect(state.cameraOpen).toBe(true); - expect(state.deviceHistory).toEqual([{ address: "192.168.1.100" }]); expect(state.loggedInUser).toEqual({ uid: "test-user" }); }); }); diff --git a/src/__tests__/unit/routes/library.favorites.test.tsx b/src/__tests__/unit/routes/library.favorites.test.tsx index ffbbcad4..c0e17b9f 100644 --- a/src/__tests__/unit/routes/library.favorites.test.tsx +++ b/src/__tests__/unit/routes/library.favorites.test.tsx @@ -6,6 +6,7 @@ import type { SearchResultsResponse } from "@/lib/models"; import { useLibrarySessionStore } from "@/lib/librarySessionStore"; import { useStatusStore } from "@/lib/store"; import { LibraryFavorites } from "@/routes/library.favorites"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn(), @@ -88,14 +89,14 @@ function favoritesResponse( } describe("Library Favorites route", () => { - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); CoreAPI.reset(); mockNavigate.mockClear(); useLibrarySessionStore.getState().reset(); + await seedActiveDevice({ recordId: "device-a" }); useStatusStore.setState({ connected: true, - targetDeviceAddress: "device-a", coreVersion: "2.15.0", coreVersionPending: false, gamesIndex: { exists: true, indexing: false }, diff --git a/src/__tests__/unit/routes/library.index.test.tsx b/src/__tests__/unit/routes/library.index.test.tsx index ad3ef8b9..19d16fe8 100644 --- a/src/__tests__/unit/routes/library.index.test.tsx +++ b/src/__tests__/unit/routes/library.index.test.tsx @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { render, screen } from "@/test-utils"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; +import { deviceRegistry } from "@/lib/devices/deviceRegistry"; import { CoreAPI } from "@/lib/coreApi"; import { useStatusStore } from "@/lib/store"; import { @@ -55,16 +57,16 @@ vi.mock("@/hooks/useHaptics", () => ({ })); describe("Library index route", () => { - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); CoreAPI.reset(); mockNavigate.mockClear(); usePreferencesStore.setState({ systemNameRegion: "auto" }); useLibrarySessionStore.getState().reset(); useTabSessionStore.getState().reset(); + await seedActiveDevice({ recordId: "device-a" }); useStatusStore.setState({ connected: true, - targetDeviceAddress: "device-a", coreVersion: "2.15.0", coreVersionPending: false, gamesIndex: { @@ -385,7 +387,7 @@ describe("Library index route", () => { ); view.unmount(); - useStatusStore.setState({ targetDeviceAddress: "device-b" }); + await deviceRegistry.selectAddress("192.168.1.55"); render(); expect( diff --git a/src/__tests__/unit/routes/library.search.test.tsx b/src/__tests__/unit/routes/library.search.test.tsx index 747c19e7..cc252ac2 100644 --- a/src/__tests__/unit/routes/library.search.test.tsx +++ b/src/__tests__/unit/routes/library.search.test.tsx @@ -5,6 +5,7 @@ import { CoreAPI } from "@/lib/coreApi"; import { useStatusStore } from "@/lib/store"; import { useLibrarySessionStore } from "@/lib/librarySessionStore"; import { LibraryGameSearch } from "@/components/library/LibraryGameSearch"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; const { mockNavigate, mockSearchOptions } = vi.hoisted(() => ({ mockNavigate: vi.fn(), @@ -129,15 +130,15 @@ vi.mock("@/hooks/useHaptics", () => ({ })); describe("Library game search", () => { - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); CoreAPI.reset(); mockNavigate.mockClear(); mockSearchOptions.mockClear(); useLibrarySessionStore.getState().reset(); + await seedActiveDevice({ recordId: "device-a" }); useStatusStore.setState({ connected: true, - targetDeviceAddress: "device-a", coreVersion: "2.15.0", coreVersionPending: false, gamesIndex: { exists: true, indexing: false }, diff --git a/src/__tests__/unit/routes/library.system.test.tsx b/src/__tests__/unit/routes/library.system.test.tsx index aa360b26..4299ce89 100644 --- a/src/__tests__/unit/routes/library.system.test.tsx +++ b/src/__tests__/unit/routes/library.system.test.tsx @@ -11,6 +11,7 @@ import { } from "@/lib/librarySessionStore"; import { useTabSessionStore } from "@/lib/tabSessionStore"; import { LibrarySystem } from "@/routes/library.$system"; +import { seedActiveDevice } from "@/test-utils/deviceRegistry"; const { mockNavigate, mockOutOfRangeScroll, mockScrollToIndex } = vi.hoisted( () => ({ @@ -118,7 +119,7 @@ function metadataResult(name: string, path: string) { } describe("Library system browser", () => { - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); CoreAPI.reset(); mockNavigate.mockReset(); @@ -131,9 +132,9 @@ describe("Library system browser", () => { nfcAvailable: true, showFilenames: false, }); + await seedActiveDevice({ recordId: "device-a" }); useStatusStore.setState({ connected: true, - targetDeviceAddress: "device-a", coreVersion: "2.15.0", coreVersionPending: false, corePlatform: null, diff --git a/src/__tests__/unit/routes/settings.devices-detail.test.tsx b/src/__tests__/unit/routes/settings.devices-detail.test.tsx index d5e0d653..2e6e0339 100644 --- a/src/__tests__/unit/routes/settings.devices-detail.test.tsx +++ b/src/__tests__/unit/routes/settings.devices-detail.test.tsx @@ -2,27 +2,29 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "../../../test-utils"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { encodeDeviceAddress } from "@/lib/deviceUrl"; - -const { - componentRef, - mockNavigate, - mockSelectDevice, - mockParams, - mockCoreReset, - mockSetDeviceAddress, - mockGetDeviceAddress, - mockUseDeviceLinking, -} = vi.hoisted(() => ({ - componentRef: { current: null as any }, - mockNavigate: vi.fn(), - mockSelectDevice: vi.fn(), - mockParams: { current: { address: "192.168.1.50" } }, - mockCoreReset: vi.fn(), - mockSetDeviceAddress: vi.fn(), - mockGetDeviceAddress: vi.fn(() => "192.168.1.10"), - mockUseDeviceLinking: vi.fn(), -})); +import { CoreAPI } from "@/lib/coreApi"; +import { + credentialKeyForRecord, + credentialStore, + type StoredCredentials, +} from "@/lib/crypto/credentials"; +import { + deviceRegistry, + type DeviceRecord, +} from "@/lib/devices/deviceRegistry"; +import { ConnectionState, useStatusStore } from "@/lib/store"; +import { + mockDeviceRecord, + seedDeviceRegistry, +} from "@/test-utils/deviceRegistry"; + +const { componentRef, mockNavigate, mockParams, mockUseDeviceLinking } = + vi.hoisted(() => ({ + componentRef: { current: null as any }, + mockNavigate: vi.fn(), + mockParams: { current: { recordId: "" } }, + mockUseDeviceLinking: vi.fn(), + })); vi.mock("@tanstack/react-router", async (importOriginal) => { const actual = (await importOriginal()) as any; @@ -47,77 +49,49 @@ vi.mock("@/hooks/usePageHeadingFocus", () => ({ usePageHeadingFocus: vi.fn(), })); +const mockIsConnected = vi.fn(() => true); vi.mock("@/hooks/useConnection", () => ({ - useConnection: () => ({ isConnected: true }), -})); - -vi.mock("@/hooks/useSelectDevice", () => ({ - useSelectDevice: () => ({ - selectDevice: mockSelectDevice, - selectScanDevice: vi.fn(), - }), + useConnection: () => ({ isConnected: mockIsConnected() }), })); vi.mock("@/hooks/useDeviceLinking", () => ({ useDeviceLinking: (enabled: boolean) => mockUseDeviceLinking(enabled), })); -vi.mock("@/lib/coreApi", () => ({ - CoreAPI: { reset: mockCoreReset }, - getDeviceAddress: () => mockGetDeviceAddress(), - setDeviceAddress: (v: string) => mockSetDeviceAddress(v), -})); - -vi.mock("@/lib/crypto/credentials", () => ({ - credentialStore: { list: vi.fn().mockResolvedValue([]) }, - normalizeDeviceKey: (s: string) => - s - .toLowerCase() - .replace(/^wss?:\/\//, "") - .replace(/\/$/, ""), -})); - -const mockUseStatusStore = vi.fn(); -const mockUpdateDeviceHistoryMeta = vi.fn(); -const mockRemoveDeviceHistory = vi.fn(); -const mockResetConnectionState = vi.fn(); -const mockSetTargetDeviceAddress = vi.fn(); - -vi.mock("@/lib/store", async (importOriginal) => { - const actual = (await importOriginal()) as any; - return { - ...actual, - useStatusStore: (selector: any) => mockUseStatusStore(selector), - }; -}); - -import "@/routes/settings.devices_.$address"; +import "@/routes/settings.devices_.$recordId"; const getDeviceDetail = () => componentRef.current; +const credentials: StoredCredentials = { + authToken: "token-abc", + pairingKey: "a".repeat(64), + clientId: "client-uuid-1234", + pairedAt: 1700000000000, +}; + describe("Settings Device Detail Route", () => { let queryClient: QueryClient; + let record: DeviceRecord; + + /** Seed the viewed record, optionally as the active device. */ + async function seedRecord(isActive = false): Promise { + record = mockDeviceRecord({ + address: "192.168.1.50", + name: "Living Room", + platform: "linux", + version: "1.0.0", + lastConnectedAt: new Date("2026-01-01T12:00:00Z").getTime(), + }); + const other = mockDeviceRecord({ address: "192.168.1.10", name: "Other" }); + await seedDeviceRegistry( + [record, other], + isActive ? record.recordId : other.recordId, + ); + mockParams.current = { recordId: record.recordId }; + return record; + } - const sampleEntry = { - address: "192.168.1.50", - name: "Living Room", - platform: "linux", - version: "1.0.0", - lastConnectedAt: new Date("2026-01-01T12:00:00Z").getTime(), - }; - - const buildState = (overrides: Partial = {}) => ({ - deviceHistory: [sampleEntry], - removeDeviceHistory: mockRemoveDeviceHistory, - updateDeviceHistoryMeta: mockUpdateDeviceHistoryMeta, - setTargetDeviceAddress: mockSetTargetDeviceAddress, - resetConnectionState: mockResetConnectionState, - safeInsets: { top: "0px", bottom: "0px", left: "0px", right: "0px" }, - coreVersion: "2.16.0", - ...overrides, - }); - - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); queryClient = new QueryClient({ defaultOptions: { @@ -125,15 +99,12 @@ describe("Settings Device Detail Route", () => { mutations: { retry: false }, }, }); - mockParams.current = { - address: encodeDeviceAddress("192.168.1.50"), - }; - mockGetDeviceAddress.mockReturnValue("192.168.1.10"); + mockIsConnected.mockReturnValue(true); mockUseDeviceLinking.mockReturnValue({ state: "unlinked", linkDevice: vi.fn(), }); - mockUseStatusStore.mockImplementation((selector) => selector(buildState())); + await seedRecord(); }); afterEach(() => { @@ -149,7 +120,7 @@ describe("Settings Device Detail Route", () => { ); }; - it("should render the entry's metadata", () => { + it("should render the record's metadata", () => { renderRoute(); expect(screen.getAllByText("Living Room").length).toBeGreaterThan(0); @@ -164,7 +135,7 @@ describe("Settings Device Detail Route", () => { ).toBeInTheDocument(); }); - it("disables Save until the name draft changes", async () => { + it("should disable Save until the name draft changes", async () => { const user = userEvent.setup(); renderRoute(); @@ -178,7 +149,7 @@ describe("Settings Device Detail Route", () => { expect(saveButton).toBeEnabled(); }); - it("calls updateDeviceHistoryMeta with the trimmed name on Save", async () => { + it("should store the trimmed name as the user's own on Save", async () => { const user = userEvent.setup(); renderRoute(); @@ -187,14 +158,14 @@ describe("Settings Device Detail Route", () => { await user.type(input, " Bedroom "); await user.click(screen.getByRole("button", { name: "save" })); - expect(mockUpdateDeviceHistoryMeta).toHaveBeenCalledWith( - "192.168.1.50", - { name: "Bedroom" }, - { source: "manual" }, - ); + await waitFor(() => { + expect( + deviceRegistry.getSnapshot().records[record.recordId], + ).toMatchObject({ name: "Bedroom", nameIsCustom: true }); + }); }); - it("clears the custom name when Save is pressed with empty input", async () => { + it("should hand the name back to the device when Save is pressed empty", async () => { const user = userEvent.setup(); renderRoute(); @@ -202,15 +173,21 @@ describe("Settings Device Detail Route", () => { await user.clear(input); await user.click(screen.getByRole("button", { name: "save" })); - expect(mockUpdateDeviceHistoryMeta).toHaveBeenCalledWith( - "192.168.1.50", - { name: undefined }, - { source: "manual" }, - ); + await waitFor(() => { + const updated = deviceRegistry.getSnapshot().records[record.recordId]; + expect(updated?.name).toBeUndefined(); + expect(updated?.nameIsCustom).toBe(false); + }); }); - it("hides 'Use this device' on the active connected device", () => { - mockGetDeviceAddress.mockReturnValue("192.168.1.50"); + it("should hide 'Use this device' on the active connected device", async () => { + await seedRecord(true); + // Device linking is only offered to a signed-in user; without one the + // section renders its sign-in prompt instead. + useStatusStore.setState({ + loggedInUser: { uid: "test-user" } as never, + coreVersion: "2.16.0", + }); renderRoute(); expect( @@ -227,7 +204,7 @@ describe("Settings Device Detail Route", () => { ).toBeInTheDocument(); }); - it("navigates back to the device list without resetting scroll", async () => { + it("should navigate back to the device list without resetting scroll", async () => { const user = userEvent.setup(); renderRoute(); @@ -239,7 +216,7 @@ describe("Settings Device Detail Route", () => { }); }); - it("calls selectDevice when 'Use this device' is tapped", async () => { + it("should make the record active when 'Use this device' is tapped", async () => { const user = userEvent.setup(); renderRoute(); @@ -249,12 +226,18 @@ describe("Settings Device Detail Route", () => { }), ); - expect(mockSelectDevice).toHaveBeenCalledWith("192.168.1.50"); + await waitFor(() => { + expect(deviceRegistry.getSnapshot().activeRecordId).toBe(record.recordId); + }); expect(mockNavigate).toHaveBeenCalledWith({ to: "/settings" }); }); - it("opens the forget confirm modal and removes the device on confirm", async () => { + it("should forget the device and its pairing on confirm", async () => { const user = userEvent.setup(); + await credentialStore.set( + credentialKeyForRecord(record.recordId), + credentials, + ); renderRoute(); await user.click( @@ -273,16 +256,34 @@ describe("Settings Device Detail Route", () => { }), ); - expect(mockRemoveDeviceHistory).toHaveBeenCalledWith("192.168.1.50"); + await waitFor(() => { + expect( + deviceRegistry.getSnapshot().records[record.recordId], + ).toBeUndefined(); + }); + // Leaving the pairing behind would let a later device at the same address + // inherit it. + await expect( + credentialStore.get(credentialKeyForRecord(record.recordId)), + ).resolves.toBeNull(); expect(mockNavigate).toHaveBeenCalledWith({ to: "/settings/devices", replace: true, }); }); - it("clears connection state when forgetting the active device", async () => { - mockGetDeviceAddress.mockReturnValue("192.168.1.50"); + it("should tear the connection down when forgetting the active device", async () => { + await seedRecord(true); const user = userEvent.setup(); + useStatusStore.setState({ + connectionState: ConnectionState.CONNECTED, + connected: true, + connectionError: "stale error", + }); + CoreAPI.setWsInstance({ + isConnected: true, + send: () => {}, + } as unknown as Parameters[0]); renderRoute(); await user.click( @@ -294,14 +295,18 @@ describe("Settings Device Detail Route", () => { }), ); - expect(mockSetDeviceAddress).toHaveBeenCalledWith(""); - expect(mockSetTargetDeviceAddress).toHaveBeenCalledWith(""); - expect(mockResetConnectionState).toHaveBeenCalled(); - expect(mockCoreReset).toHaveBeenCalled(); + await waitFor(() => { + expect(useStatusStore.getState().connectionState).toBe( + ConnectionState.IDLE, + ); + }); + expect(useStatusStore.getState().connectionError).toBe(""); + expect(CoreAPI.isConnected()).toBe(false); + expect(deviceRegistry.getSnapshot().activeRecordId).toBeNull(); }); - it("redirects to the device list when the address is unknown", () => { - mockParams.current = { address: encodeDeviceAddress("unknown.device") }; + it("should redirect to the device list when the record is unknown", () => { + mockParams.current = { recordId: "record-that-was-forgotten" }; renderRoute(); expect(mockNavigate).toHaveBeenCalledWith({ diff --git a/src/__tests__/unit/routes/settings.devices.test.tsx b/src/__tests__/unit/routes/settings.devices.test.tsx index 0b1ec4e2..f581412c 100644 --- a/src/__tests__/unit/routes/settings.devices.test.tsx +++ b/src/__tests__/unit/routes/settings.devices.test.tsx @@ -2,13 +2,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "../../../test-utils"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { encodeDeviceAddress } from "@/lib/deviceUrl"; -import { useStatusStore } from "@/lib/store"; - -const { componentRef, mockNavigate, mockSelectDevice } = vi.hoisted(() => ({ +import { Preferences } from "@capacitor/preferences"; +import { + credentialKeyForRecord, + credentialStore, + type StoredCredentials, +} from "@/lib/crypto/credentials"; +import { deviceRegistry } from "@/lib/devices/deviceRegistry"; +import { + mockDeviceRecord, + seedDeviceRegistry, + type DeviceRecordOptions, +} from "@/test-utils/deviceRegistry"; + +const { componentRef, mockNavigate } = vi.hoisted(() => ({ componentRef: { current: null as any }, mockNavigate: vi.fn(), - mockSelectDevice: vi.fn(), })); vi.mock("@tanstack/react-router", async (importOriginal) => { @@ -34,7 +43,7 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { className?: string; }) => { const href = params - ? to.replace(/\$(\w+)/g, (_m, k) => params[k] ?? "") + ? to.replace(/\$(\w+)/g, (_m, key) => params[key] ?? "") : to; return ( @@ -57,48 +66,28 @@ vi.mock("@/hooks/useConnection", () => ({ useConnection: () => ({ isConnected: true }), })); -vi.mock("@/hooks/useSelectDevice", () => ({ - useSelectDevice: () => ({ - selectDevice: mockSelectDevice, - selectScanDevice: vi.fn(), - }), -})); - -vi.mock("@/lib/coreApi", () => ({ - CoreAPI: { reset: vi.fn() }, - getDeviceAddress: vi.fn(() => "192.168.1.10"), - setDeviceAddress: vi.fn(), -})); - -const mockCredentialList = vi.fn(); -vi.mock("@/lib/crypto/credentials", () => ({ - credentialStore: { - list: () => mockCredentialList(), - }, - normalizeDeviceKey: (s: string) => - s - .toLowerCase() - .replace(/^wss?:\/\//, "") - .replace(/\/$/, ""), -})); - import "@/routes/settings.devices"; const getDevices = () => componentRef.current; -type DeviceEntry = { - address: string; - name?: string; - platform?: string; - version?: string; +const credentials: StoredCredentials = { + authToken: "token-abc", + pairingKey: "a".repeat(64), + clientId: "client-uuid-1234", + pairedAt: 1700000000000, }; -const seedDeviceHistory = (deviceHistory: DeviceEntry[]) => { - useStatusStore.setState({ - deviceHistory, - safeInsets: { top: "0px", bottom: "0px", left: "0px", right: "0px" }, - }); -}; +async function seedRecords( + entries: DeviceRecordOptions[], + activeIndex: number | null = null, +) { + const records = entries.map((entry) => mockDeviceRecord(entry)); + await seedDeviceRegistry( + records, + activeIndex === null ? null : (records[activeIndex]?.recordId ?? null), + ); + return records; +} describe("Settings Devices Route", () => { let queryClient: QueryClient; @@ -111,8 +100,6 @@ describe("Settings Devices Route", () => { mutations: { retry: false }, }, }); - mockCredentialList.mockResolvedValue([]); - seedDeviceHistory([]); }); afterEach(() => { @@ -129,16 +116,33 @@ describe("Settings Devices Route", () => { }; it("should render the empty state when no devices are saved", async () => { + await seedDeviceRegistry([]); renderRoute(); - await waitFor(() => { - expect( - screen.getByText("settings.deviceHistoryEmpty"), - ).toBeInTheDocument(); - }); + + expect( + await screen.findByText("settings.deviceHistoryEmpty"), + ).toBeInTheDocument(); }); - it("should render one row per device entry, sorted alphabetically", async () => { - seedDeviceHistory([ + it("should distinguish a failed registry read from having no devices", async () => { + // Telling a user whose registry failed to load that they have never saved a + // device invites them to re-pair devices they already own. + vi.mocked(Preferences.get).mockRejectedValueOnce( + new Error("storage unavailable"), + ); + await deviceRegistry.hydrate(); + renderRoute(); + + expect( + await screen.findByText("settings.deviceHistoryError"), + ).toBeInTheDocument(); + expect( + screen.queryByText("settings.deviceHistoryEmpty"), + ).not.toBeInTheDocument(); + }); + + it("should render one row per record, sorted alphabetically", async () => { + await seedRecords([ { address: "192.168.1.10", name: "Zulu" }, { address: "192.168.1.11", name: "Alpha" }, { address: "192.168.1.12", name: "Mike" }, @@ -149,69 +153,98 @@ describe("Settings Devices Route", () => { await waitFor(() => { const names = screen .getAllByText(/Alpha|Mike|Zulu/) - .map((el) => el.textContent); + .map((element) => element.textContent); expect(names).toEqual(["Alpha", "Mike", "Zulu"]); }); }); it("should mark the currently connected device as active", async () => { - seedDeviceHistory([ - { address: "192.168.1.10", name: "Active" }, - { address: "192.168.1.11", name: "Other" }, + await seedRecords( + [ + { address: "192.168.1.10", name: "Active" }, + { address: "192.168.1.11", name: "Other" }, + ], + 0, + ); + + renderRoute(); + + expect( + await screen.findByLabelText("settings.activeDevice"), + ).toBeInTheDocument(); + }); + + it("should show the lock icon only on rows whose record holds credentials", async () => { + const [, paired] = await seedRecords([ + { address: "192.168.1.10", name: "Unpaired" }, + { address: "192.168.1.11", name: "Paired" }, ]); + await credentialStore.set( + credentialKeyForRecord(paired!.recordId), + credentials, + ); renderRoute(); await waitFor(() => { - expect( - screen.getByLabelText("settings.activeDevice"), - ).toBeInTheDocument(); + expect(screen.getAllByLabelText("connection.encrypted")).toHaveLength(1); }); }); - it("should show the lock icon only on rows with stored credentials", async () => { - mockCredentialList.mockResolvedValue([{ deviceKey: "192.168.1.11" }]); - seedDeviceHistory([ - { address: "192.168.1.10", name: "Unpaired" }, - { address: "192.168.1.11", name: "Paired" }, + it("should show the lock icon for a migrated record still on its pre-V2 key", async () => { + // The pairing only moves to the canonical key on the first encrypted + // connect, so until then the address key is where it lives. + await seedRecords([ + { + address: "192.168.1.10", + name: "Migrated", + legacyCredentialKey: "192.168.1.10", + }, ]); + await credentialStore.set("192.168.1.10", credentials); renderRoute(); await waitFor(() => { - const locks = screen.getAllByLabelText("connection.encrypted"); - expect(locks).toHaveLength(1); + expect(screen.getAllByLabelText("connection.encrypted")).toHaveLength(1); }); }); - it("should call selectDevice and navigate back to /settings on row tap", async () => { + it("should make the tapped record active and return to settings", async () => { const user = userEvent.setup(); - seedDeviceHistory([{ address: "192.168.1.20", name: "Pick me" }]); + const [first, second] = await seedRecords( + [ + { address: "192.168.1.20", name: "Pick me" }, + { address: "192.168.1.21", name: "Not me" }, + ], + 1, + ); + expect(deviceRegistry.getSnapshot().activeRecordId).toBe(second!.recordId); renderRoute(); - await user.click(screen.getByText("Pick me")); - expect(mockSelectDevice).toHaveBeenCalledWith("192.168.1.20"); + await waitFor(() => { + expect(deviceRegistry.getSnapshot().activeRecordId).toBe(first!.recordId); + }); expect(mockNavigate).toHaveBeenCalledWith({ to: "/settings" }); }); - it("should render an info link per row pointing to /settings/devices/$address", async () => { - seedDeviceHistory([{ address: "192.168.1.30", name: "With Info" }]); + it("should link each row to its record's detail page", async () => { + const [record] = await seedRecords([ + { address: "192.168.1.30", name: "With Info" }, + ]); renderRoute(); - await waitFor(() => { - const infoLink = screen.getByLabelText("settings.deviceDetails"); - expect(infoLink).toHaveAttribute( - "href", - `/settings/devices/${encodeDeviceAddress("192.168.1.30")}`, - ); - }); + expect( + await screen.findByLabelText("settings.deviceDetails"), + ).toHaveAttribute("href", `/settings/devices/${record!.recordId}`); }); it("should navigate to Settings without resetting scroll", async () => { const user = userEvent.setup(); + await seedDeviceRegistry([]); renderRoute(); await user.click(screen.getByLabelText("nav.back")); diff --git a/src/__tests__/unit/routes/settings.index.test.tsx b/src/__tests__/unit/routes/settings.index.test.tsx index 9f9a0942..7c7fbc6b 100644 --- a/src/__tests__/unit/routes/settings.index.test.tsx +++ b/src/__tests__/unit/routes/settings.index.test.tsx @@ -7,6 +7,7 @@ import { within, } from "../../../test-utils"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { deviceRegistry } from "@/lib/devices/deviceRegistry"; import { usePurchasePreviewStore } from "@/lib/purchasePreviewStore"; // Mock CoreAPI @@ -14,8 +15,6 @@ vi.mock("@/lib/coreApi", () => ({ CoreAPI: { reset: vi.fn(), }, - getDeviceAddress: vi.fn(() => "192.168.1.100"), - setDeviceAddress: vi.fn(), validateDeviceAddress: vi.fn((address: string) => { if (address.includes("286")) { return { @@ -196,7 +195,6 @@ describe("Settings Index Route", () => { setDeviceHistory: vi.fn(), removeDeviceHistory: vi.fn(), resetConnectionState: vi.fn(), - setTargetDeviceAddress: vi.fn(), safeInsets: { top: "0px", bottom: "0px", left: "0px", right: "0px" }, inboxMessages: [], setInboxModalOpen: vi.fn(), @@ -347,13 +345,11 @@ describe("Settings Index Route", () => { describe("device address changes", () => { it("should reset connection state when address changes", async () => { const mockResetConnectionState = vi.fn(); - const mockSetTargetDeviceAddress = vi.fn(); mockUseStatusStore.mockImplementation((selector) => selector({ ...defaultStoreState, resetConnectionState: mockResetConnectionState, - setTargetDeviceAddress: mockSetTargetDeviceAddress, }), ); @@ -380,13 +376,11 @@ describe("Settings Index Route", () => { it("should show validation message and not select invalid address", async () => { const mockResetConnectionState = vi.fn(); - const mockSetTargetDeviceAddress = vi.fn(); mockUseStatusStore.mockImplementation((selector) => selector({ ...defaultStoreState, resetConnectionState: mockResetConnectionState, - setTargetDeviceAddress: mockSetTargetDeviceAddress, }), ); @@ -398,7 +392,9 @@ describe("Settings Index Route", () => { await screen.findByText("settings.deviceAddressInvalid"), ).toBeInTheDocument(); expect(mockResetConnectionState).not.toHaveBeenCalled(); - expect(mockSetTargetDeviceAddress).not.toHaveBeenCalled(); + // An address that fails validation must not reach the registry — a record + // written here would outlive the error message. + expect(deviceRegistry.getSnapshot().records).toEqual({}); }); it("should clear validation message after a valid address", async () => { diff --git a/src/components/ConnectionProvider.tsx b/src/components/ConnectionProvider.tsx index f4e5a32a..a2d01cce 100644 --- a/src/components/ConnectionProvider.tsx +++ b/src/components/ConnectionProvider.tsx @@ -15,7 +15,6 @@ import { } from "react"; import { useShallow } from "zustand/react/shallow"; import { Capacitor, type PluginListenerHandle } from "@capacitor/core"; -import { Preferences } from "@capacitor/preferences"; import { App } from "@capacitor/app"; import { Network } from "@capacitor/network"; import toast from "react-hot-toast"; @@ -56,10 +55,8 @@ import { satisfies as versionSatisfies } from "@/lib/coreVersion"; import { CoreAPI, CoreApiError, - getDeviceAddress, isCancelled, isExpectedMediaDatabaseError, - validateDeviceAddress, type NotificationRequest, } from "@/lib/coreApi"; import { @@ -68,11 +65,18 @@ import { useStatusStore, ConnectionState, } from "@/lib/store"; -import { credentialStore, normalizeDeviceKey } from "@/lib/crypto/credentials"; import { - isNativePluginAvailable, - isPluginAvailable, -} from "@/lib/capacitorBridge"; + credentialKeyForRecord, + credentialStore, +} from "@/lib/crypto/credentials"; +import { + activeAddressOf, + deviceRegistry, + parsedEndpointForRecord, + useDeviceRegistry, + type DeviceRegistrySnapshot, +} from "@/lib/devices/deviceRegistry"; +import { isNativePluginAvailable } from "@/lib/capacitorBridge"; import { formatDurationDisplay, formatDurationAccessible } from "@/lib/utils"; import { ConnectionContext, @@ -85,6 +89,21 @@ interface ConnectionProviderProps { children: ReactNode; } +const selectActiveRecordId = (state: DeviceRegistrySnapshot) => + state.activeRecordId; + +/** + * The WebSocket URL the active record connects through, or `""` when there is + * no usable device. Selected as a primitive so a metadata-only registry write + * cannot tear the socket down and rebuild it. + */ +const selectActiveWsUrl = (state: DeviceRegistrySnapshot) => { + const record = state.activeRecordId + ? state.records[state.activeRecordId] + : null; + return parsedEndpointForRecord(record)?.wsUrl ?? ""; +}; + const CLIENT_CAPABILITIES_SINCE = "2.16.0"; const MEDIA_TRANSITION_GRACE_MS = 250; const MEDIA_INDEX_RECONCILE_MS = 2000; @@ -169,8 +188,6 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { // Store state const { - targetDeviceAddress, - setTargetDeviceAddress, setConnectionState, setConnectionError, setPlaying, @@ -183,9 +200,6 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { clearActiveTokens, setStagedToken, clearStagedToken, - addDeviceHistory, - setDeviceHistory, - updateDeviceHistoryMeta, setCoreVersion, setCorePlatform, setCoreVersionPending, @@ -197,8 +211,6 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { setInboxModalOpen, } = useStatusStore( useShallow((state) => ({ - targetDeviceAddress: state.targetDeviceAddress, - setTargetDeviceAddress: state.setTargetDeviceAddress, setConnectionState: state.setConnectionState, setConnectionError: state.setConnectionError, setPlaying: state.setPlaying, @@ -211,9 +223,6 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { clearActiveTokens: state.clearActiveTokens, setStagedToken: state.setStagedToken, clearStagedToken: state.clearStagedToken, - addDeviceHistory: state.addDeviceHistory, - setDeviceHistory: state.setDeviceHistory, - updateDeviceHistoryMeta: state.updateDeviceHistoryMeta, setCoreVersion: state.setCoreVersion, setCorePlatform: state.setCorePlatform, setCoreVersionPending: state.setCoreVersionPending, @@ -226,6 +235,10 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { })), ); + const activeRecordId = useDeviceRegistry(selectActiveRecordId); + const connectionWsUrl = useDeviceRegistry(selectActiveWsUrl); + const connectionAddress = useDeviceRegistry(activeAddressOf); + // Connection state tracked via useState to prevent unnecessary re-renders // These are updated via onConnectionChange callback const [localConnection, setLocalConnection] = @@ -242,7 +255,7 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { !isConnected && !hasConnectedBefore && (localConnection === null - ? targetDeviceAddress !== "" + ? connectionWsUrl !== "" : localConnection.state === "connecting" || localConnection.state === "reconnecting"); @@ -541,7 +554,7 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { queryClient.removeQueries({ queryKey: [LIBRARY_QUERY_KEYS.image], }); - invalidateLibraryImageCache(targetDeviceAddress); + invalidateLibraryImageCache(activeRecordId ?? ""); } break; } @@ -742,7 +755,7 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { announce, addInboxMessage, setInboxModalOpen, - targetDeviceAddress, + activeRecordId, ], ); @@ -756,156 +769,125 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { // Flush any queued API requests CoreAPI.flushQueue(); - // Hydrate device history from Preferences first, THEN call version() and - // merge platform/version metadata in .finally(). Do not parallelise: a - // late setDeviceHistory(stored) would wholesale overwrite the - // freshly-merged metadata from updateDeviceHistoryMeta(). + // Fetch Core version for feature gating, then persist platform/version on + // the device record so the device list can render them between connects. setCoreVersionPending(true); - const deviceHistory = isPluginAvailable("Preferences") - ? Preferences.get({ key: "deviceHistory" }) - : Promise.resolve({ value: null }); - - deviceHistory - .then((v) => { - try { - if (v.value) { - setDeviceHistory(JSON.parse(v.value)); - } - addDeviceHistory(getDeviceAddress()); - } catch (e) { - logger.error("Error processing device history:", e); - toast.error(t("error", { msg: "Failed to load device history" })); + CoreAPI.version() + .then((res) => { + if (isCancelled(res)) { + // Don't clear pending — a new connection will manage its own pending state + logger.log("Version request was cancelled, skipping"); + return; + } + if (clientRequestToken !== currentClientRequestToken.current) { + return; + } + setCoreVersion(res.version); + setCorePlatform(res.platform); + setCoreVersionPending(false); + const recordId = deviceRegistry.getSnapshot().activeRecordId; + if (recordId) { + void deviceRegistry.applyDiscoveredMetadata(recordId, { + platform: res.platform, + version: res.version, + }); } - }) - .catch((e) => { - logger.error("Failed to get device history:", e); - }) - .finally(() => { - // Fetch Core version for feature gating, then persist platform/version - // on the device-history entry so the device list can render them - // between connects. lastConnectedAt powers the "recently used" - // sort/subtitle (no UI yet, but stored for free). - CoreAPI.version() - .then((res) => { - if (isCancelled(res)) { - // Don't clear pending — a new connection will manage its own pending state - logger.log("Version request was cancelled, skipping"); - return; - } - if (clientRequestToken !== currentClientRequestToken.current) { - return; - } - setCoreVersion(res.version); - setCorePlatform(res.platform); - setCoreVersionPending(false); - updateDeviceHistoryMeta(getDeviceAddress(), { - platform: res.platform, - version: res.version, - lastConnectedAt: Date.now(), - }); - if (versionSatisfies(res.version, CLIENT_CAPABILITIES_SINCE)) { - CoreAPI.clientsCurrent() - .then((clientRes) => { - if ( - clientRequestToken !== currentClientRequestToken.current - ) { - return; - } - if (isCancelled(clientRes)) { - logger.log( - "Current client request was cancelled, skipping", - ); - return; - } - setCurrentClient(clientRes); - }) - .catch((err) => { - if ( - clientRequestToken !== currentClientRequestToken.current - ) { - return; - } - setCurrentClient(null); - if (err instanceof CoreApiError && err.code === -32601) { - logger.warn( - "Current client capabilities are unavailable on this Core build", - ); - return; - } - logger.error( - "Failed to fetch current client capabilities:", - err, - { - category: "api", - action: "clientsCurrent", - severity: "warning", - }, - ); - }); - } else { - // Roles did not exist before Core 2.16, so older connections - // retain their legacy unrestricted settings access. - setCurrentClient(LEGACY_CLIENT_ACCESS); - } + if (versionSatisfies(res.version, CLIENT_CAPABILITIES_SINCE)) { + CoreAPI.clientsCurrent() + .then((clientRes) => { + if (clientRequestToken !== currentClientRequestToken.current) { + return; + } + if (isCancelled(clientRes)) { + logger.log("Current client request was cancelled, skipping"); + return; + } + setCurrentClient(clientRes); + }) + .catch((err) => { + if (clientRequestToken !== currentClientRequestToken.current) { + return; + } + setCurrentClient(null); + if (err instanceof CoreApiError && err.code === -32601) { + logger.warn( + "Current client capabilities are unavailable on this Core build", + ); + return; + } + logger.error( + "Failed to fetch current client capabilities:", + err, + { + category: "api", + action: "clientsCurrent", + severity: "warning", + }, + ); + }); + } else { + // Roles did not exist before Core 2.16, so older connections + // retain their legacy unrestricted settings access. + setCurrentClient(LEGACY_CLIENT_ACCESS); + } - if (isCoreFeatureAvailable("mediaScrapers", res.version)) { - CoreAPI.mediaScrapeStatus() - .then((statusRes) => { - if (isCancelled(statusRes)) { - logger.log( - "Media scrape status request was cancelled, skipping", - ); - return; - } - setScrapingStatus(statusRes); - }) - .catch((err) => { - setScrapingStatus(null); - logger.error("Failed to fetch media scrape status:", err, { - category: "api", - action: "mediaScrapeStatus", - severity: "warning", - }); - }); - } else { + if (isCoreFeatureAvailable("mediaScrapers", res.version)) { + CoreAPI.mediaScrapeStatus() + .then((statusRes) => { + if (isCancelled(statusRes)) { + logger.log( + "Media scrape status request was cancelled, skipping", + ); + return; + } + setScrapingStatus(statusRes); + }) + .catch((err) => { setScrapingStatus(null); - } - if (isCoreFeatureAvailable("inbox", res.version)) { - CoreAPI.inbox() - .then((inboxRes) => { - if (isCancelled(inboxRes)) { - logger.log("Inbox request was cancelled, skipping"); - return; - } - setInboxMessages(inboxRes.messages); - }) - .catch((err) => { - logger.error("Failed to fetch inbox:", err, { - category: "api", - action: "inbox", - severity: "warning", - }); - }); - } else { - setInboxMessages([]); - setInboxModalOpen(false); - } - }) - .catch((e) => { - if (clientRequestToken !== currentClientRequestToken.current) { - return; - } - logger.error("Failed to get Core version:", e, { - category: "api", - action: "version", - severity: "warning", + logger.error("Failed to fetch media scrape status:", err, { + category: "api", + action: "mediaScrapeStatus", + severity: "warning", + }); }); - setCoreVersion(null); - setCorePlatform(null); - setCoreVersionPending(false); - setCurrentClient(null); - }); + } else { + setScrapingStatus(null); + } + if (isCoreFeatureAvailable("inbox", res.version)) { + CoreAPI.inbox() + .then((inboxRes) => { + if (isCancelled(inboxRes)) { + logger.log("Inbox request was cancelled, skipping"); + return; + } + setInboxMessages(inboxRes.messages); + }) + .catch((err) => { + logger.error("Failed to fetch inbox:", err, { + category: "api", + action: "inbox", + severity: "warning", + }); + }); + } else { + setInboxMessages([]); + setInboxModalOpen(false); + } + }) + .catch((e) => { + if (clientRequestToken !== currentClientRequestToken.current) { + return; + } + logger.error("Failed to get Core version:", e, { + category: "api", + action: "version", + severity: "warning", + }); + setCoreVersion(null); + setCorePlatform(null); + setCoreVersionPending(false); + setCurrentClient(null); }); // Refetch device-scoped library data after every connection. This handles @@ -1043,9 +1025,6 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { cancelMediaIndexReconciliation, scheduleMediaIndexReconciliation, setConnectionError, - setDeviceHistory, - addDeviceHistory, - updateDeviceHistoryMeta, setGamesIndex, applyMediaState, setLastToken, @@ -1067,37 +1046,14 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { processNotificationRef.current = processNotification; }, [handleConnectionOpen, processNotification]); - // Initialize device address from localStorage + // Load stored devices. Everything below reads the registry, so this is the + // one place that has to wait for storage; the connection effect simply sees + // an empty wsUrl until it lands. useEffect(() => { - if (targetDeviceAddress !== "") return; - - let attempts = 0; - const maxAttempts = 5; - const checkInterval = 100; - let timer: ReturnType | null = null; - - const checkAddress = () => { - attempts++; - const addr = getDeviceAddress(); - - if (addr !== "") { - setTargetDeviceAddress(addr); - return; - } - - if (attempts < maxAttempts) { - timer = setTimeout(checkAddress, checkInterval); - } - }; - - checkAddress(); - - return () => { - if (timer) clearTimeout(timer); - }; - }, [targetDeviceAddress, setTargetDeviceAddress]); + void deviceRegistry.hydrate(); + }, []); - // Setup connection when device address changes + // Setup connection when the active device changes useEffect(() => { // Reset local connection state when device changes so UI doesn't show stale data // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: reset state for the newly selected external device. @@ -1108,23 +1064,20 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { setPairingRequired(false); setPairingOpen(false); - if (targetDeviceAddress === "") { + if (connectionWsUrl === "" || activeRecordId === null) { setConnectionState(ConnectionState.DISCONNECTED); return; } - const addressResult = validateDeviceAddress(targetDeviceAddress); - if (!addressResult.ok) { - logger.warn( - `[ConnectionProvider] Invalid device address: ${targetDeviceAddress}`, - ); - setConnectionState(ConnectionState.ERROR); - setConnectionError(tRef.current(addressResult.errorKey)); - return; - } + const recordId = activeRecordId; + // The transport keys devices by this id, and a record id is stable across + // address changes — the point of the registry. Addresses are never used. + const deviceId = recordId; - const wsUrl = addressResult.wsUrl; - const deviceAddress = addressResult.address; + // Which key answered `getCredentials` for this connection. Non-null only + // while a pre-V2 pairing has not yet been moved to the canonical key, and + // trustworthy only after the peer authenticates with it. + let legacyKeyUsed: string | null = null; // Generate unique ID for this connection session to prevent stale events // Use crypto.randomUUID if available, fallback for older Android WebViews @@ -1138,7 +1091,7 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { CoreAPI.reset(); logger.log( - `[ConnectionProvider] Setting up connection to: ${deviceAddress} (id: ${connectionId.slice(0, 8)})`, + `[ConnectionProvider] Setting up connection to: ${connectionWsUrl} (id: ${connectionId.slice(0, 8)})`, ); // Setup connection manager event handlers @@ -1239,10 +1192,28 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { onEncryptedHandshakeOk: () => { setEncryptionState("encrypted"); setPairingRequired(false); + // The peer authenticated with whatever key answered getCredentials, so + // this is the one moment the app can be sure the record and the + // credential belong to the same physical device. Move a pre-V2 pairing + // to the canonical key now, and only drop the pointer to the old key + // once that has actually landed. + const provenKey = legacyKeyUsed; + void ( + provenKey === null + ? Promise.resolve(true) + : credentialStore.promoteRecordCredentials(recordId, provenKey) + ).then((migrationSettled) => + deviceRegistry.markConnected(recordId, { + ...(provenKey === null ? {} : { provenLegacyKey: provenKey }), + migrationSettled, + }), + ); }, onPlaintextMode: () => { setEncryptionState("plaintext"); setPairingRequired(false); + // Nothing was proven, so the legacy key pointer stays put. + void deviceRegistry.markConnected(recordId); }, onEncryptionRequired: () => { // Server demands encryption but we have no credentials — open the @@ -1264,21 +1235,15 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { onCredentialsRevoked: () => { // Server rejected our stored credentials — clear them and prompt // the user to pair again. - const deviceKey = normalizeDeviceKey(deviceAddress); - credentialStore.delete(deviceKey).catch((err) => { + // Delete exactly the key the server rejected — which may still be the + // pre-V2 one on a migrated device that has not connected since. + const revokedKey = legacyKeyUsed ?? credentialKeyForRecord(recordId); + credentialStore.delete(revokedKey).catch((err) => { logger.error("Failed to delete revoked credentials", err, { category: "storage", action: "deleteCredentials", }); }); - const updated = useStatusStore - .getState() - .deviceHistory.map((entry) => - entry.address === deviceAddress - ? { ...entry, paired: undefined } - : entry, - ); - setDeviceHistory(updated); setEncryptionState("plaintext"); setPairingRequired(true); setConnectionError( @@ -1290,16 +1255,23 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { // Add device and set as active const transport = connectionManager.addDevice({ - deviceId: deviceAddress, + deviceId, type: "websocket", - address: wsUrl, + address: connectionWsUrl, encryption: { - getCredentials: () => - credentialStore.get(normalizeDeviceKey(deviceAddress)), + getCredentials: async () => { + const record = deviceRegistry.getSnapshot().records[recordId]; + const lookup = await credentialStore.getForRecord( + recordId, + record?.legacyCredentialKey, + ); + legacyKeyUsed = lookup.legacyKeyUsed; + return lookup.credentials; + }, }, }); - connectionManager.setActiveDevice(deviceAddress); + connectionManager.setActiveDevice(deviceId); // Create a compatibility wrapper for CoreAPI const transportWrapper = { @@ -1338,13 +1310,14 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { invalidateMediaStateRequest(); // Reset CoreAPI to clear any pending requests for this connection CoreAPI.reset(); - connectionManager.removeDevice(deviceAddress); + connectionManager.removeDevice(deviceId); invalidateCurrentClientRequest(); setCurrentClient(null); setConnectionState(ConnectionState.DISCONNECTED); }; }, [ - targetDeviceAddress, + activeRecordId, + connectionWsUrl, invalidateCurrentClientRequest, invalidateMediaStateRequest, cancelMediaIndexReconciliation, @@ -1354,7 +1327,6 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { setCurrentClient, setEncryptionState, setPairingRequired, - setDeviceHistory, mapTransportState, queryClient, // Note: handleConnectionOpen, processNotification, and t are accessed via refs @@ -1473,7 +1445,8 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { setPairingOpen(false)} - address={targetDeviceAddress} + address={connectionAddress} + recordId={activeRecordId ?? ""} onSuccess={() => connectionManager.restartActiveConnection()} /> diff --git a/src/components/ConnectionStatusDisplay.tsx b/src/components/ConnectionStatusDisplay.tsx index a4729160..1fcf7797 100644 --- a/src/components/ConnectionStatusDisplay.tsx +++ b/src/components/ConnectionStatusDisplay.tsx @@ -11,7 +11,10 @@ import { } from "lucide-react"; import { useConnection } from "@/hooks/useConnection"; import { useStatusStore } from "@/lib/store"; -import { getDeviceAddress } from "@/lib/coreApi"; +import { + activeAddressOf, + useDeviceRegistry, +} from "@/lib/devices/deviceRegistry"; type ConnectionUIState = | "connecting" @@ -66,7 +69,7 @@ export function ConnectionStatusDisplay({ const { isConnected, showConnecting, showReconnecting } = useConnection(); const encryptionState = useStatusStore((s) => s.encryptionState); const pairingRequired = useStatusStore((s) => s.pairingRequired); - const savedAddress = getDeviceAddress(); + const savedAddress = useDeviceRegistry(activeAddressOf); // Derive UI state from connection context const deriveUIState = (): ConnectionUIState => { diff --git a/src/components/DeviceConnectionCard.tsx b/src/components/DeviceConnectionCard.tsx index 4ed45b64..f6e2247f 100644 --- a/src/components/DeviceConnectionCard.tsx +++ b/src/components/DeviceConnectionCard.tsx @@ -3,15 +3,25 @@ import { Capacitor } from "@capacitor/core"; import { Link } from "@tanstack/react-router"; import { ArrowLeftRightIcon, KeyRoundIcon, SearchIcon } from "lucide-react"; import { useConnection } from "@/hooks/useConnection"; -import { getDeviceAddress } from "@/lib/coreApi"; -import { normalizeDeviceKey } from "@/lib/crypto/credentials"; import { satisfies as versionSatisfies } from "@/lib/coreVersion"; +import { + activeAddressOf, + useDeviceRegistry, + type DeviceRegistrySnapshot, +} from "@/lib/devices/deviceRegistry"; import { useStatusStore } from "@/lib/store"; import { Card } from "./wui/Card"; import { Button } from "./wui/Button"; import { TextInput } from "./wui/TextInput"; import { ConnectionStatusDisplay } from "./ConnectionStatusDisplay"; +function activeRecordName(state: DeviceRegistrySnapshot): string | undefined { + const record = state.activeRecordId + ? state.records[state.activeRecordId] + : undefined; + return record?.name; +} + interface DeviceConnectionCardProps { address: string; setAddress: (address: string) => void; @@ -32,19 +42,14 @@ export function DeviceConnectionCard({ const { t } = useTranslation(); const { isConnected, openPairingModal } = useConnection(); - const savedAddress = getDeviceAddress(); + const savedAddress = useDeviceRegistry(activeAddressOf); + const activeName = useDeviceRegistry(activeRecordName); const coreVersion = useStatusStore((state) => state.coreVersion); const corePlatform = useStatusStore((state) => state.corePlatform); const coreVersionPending = useStatusStore( (state) => state.coreVersionPending, ); const currentClient = useStatusStore((state) => state.currentClient); - const deviceHistory = useStatusStore((state) => state.deviceHistory); - - const savedKey = savedAddress ? normalizeDeviceKey(savedAddress) : ""; - const currentEntry = savedKey - ? deviceHistory.find((e) => normalizeDeviceKey(e.address) === savedKey) - : undefined; const versionLabel = coreVersion !== null @@ -92,7 +97,7 @@ export function DeviceConnectionCard({ connectionError={connectionError} connectedSubtitle={deviceDetails} connectedSubtitleLoading={isConnected && coreVersionPending} - connectedName={currentEntry?.name} + connectedName={activeName} connectedTitleSuffix={clientRoleLabel} action={
diff --git a/src/components/MediaDatabaseCard.tsx b/src/components/MediaDatabaseCard.tsx index 54d94894..0df8f26a 100644 --- a/src/components/MediaDatabaseCard.tsx +++ b/src/components/MediaDatabaseCard.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { useState, useEffect } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useActiveDeviceKey } from "@/hooks/useActiveDeviceKey"; import { useCoreFeature } from "@/hooks/useCoreFeature"; import { ConnectionState, useStatusStore } from "@/lib/store"; import { @@ -31,9 +32,7 @@ export function MediaDatabaseCard({ const { t } = useTranslation(); const queryClient = useQueryClient(); const connected = useStatusStore((state) => state.connected); - const targetDeviceAddress = useStatusStore( - (state) => state.targetDeviceAddress, - ); + const deviceKey = useActiveDeviceKey(); const connectionState = useStatusStore((state) => state.connectionState); const gamesIndex = useStatusStore((state) => state.gamesIndex); const scrapingStatus = useStatusStore((state) => state.scrapingStatus); @@ -89,7 +88,7 @@ export function MediaDatabaseCard({ // Include unavailable launcher-backed systems so users can run their first // partial index for a system. CoreAPI removes virtual ZapScript launchables. const { data: systemsData } = useQuery({ - queryKey: ["systems", targetDeviceAddress, { all: true }], + queryKey: ["systems", deviceKey, { all: true }], queryFn: () => CoreAPI.systems({ all: true }), enabled: connected, }); diff --git a/src/components/MediaDetailsModal.tsx b/src/components/MediaDetailsModal.tsx index 6527ff7b..efe05f22 100644 --- a/src/components/MediaDetailsModal.tsx +++ b/src/components/MediaDetailsModal.tsx @@ -9,7 +9,7 @@ import type { SearchResultGame } from "@/lib/models"; import { isFavoriteTag, searchResultToBrowseEntry } from "@/lib/libraryMedia"; import { filenameFromPath } from "@/lib/path"; import { usePreferencesStore } from "@/lib/preferencesStore"; -import { useStatusStore } from "@/lib/store"; +import { useActiveDeviceKey } from "@/hooks/useActiveDeviceKey"; import { SlideModal } from "@/components/SlideModal"; import { TagBadge } from "@/components/TagBadge"; import { Button } from "@/components/wui/Button"; @@ -48,9 +48,7 @@ export function MediaDetailsModal({ const { t } = useTranslation(); const { impact } = useHaptics(); const showFilenames = usePreferencesStore((state) => state.showFilenames); - const targetDeviceAddress = useStatusStore( - (state) => state.targetDeviceAddress, - ); + const deviceKey = useActiveDeviceKey(); const resolveSystemName = useSystemNameResolver(); const radioGroupName = useId(); const pathInputId = `${radioGroupName}-path`; @@ -304,7 +302,7 @@ export function MediaDetailsModal({ )}