diff --git a/webclient/eslint.boundaries.mjs b/webclient/eslint.boundaries.mjs index 05bb6cf83..d2ca95aa1 100644 --- a/webclient/eslint.boundaries.mjs +++ b/webclient/eslint.boundaries.mjs @@ -19,15 +19,15 @@ const types = (...types) => types.map((type) => ({ to: { type } })); const rules = [ { from: { type: 'generated' }, allow: [] }, + { from: { type: 'websocket' }, allow: types('generated') }, { from: { type: 'types' }, allow: types('generated', 'websocket') }, - { from: { type: 'websocket' }, allow: types('generated') }, { from: { type: 'store' }, allow: types('types') }, { from: { type: 'api' }, allow: types('store', 'types', 'websocket') }, - { from: { type: 'hooks' }, allow: types('api', 'services', 'types', 'websocket') }, { from: { type: 'images' }, allow: types('types') }, { from: { type: 'services' }, allow: types('api', 'store', 'types') }, + { from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket') }, { from: { type: 'components' }, diff --git a/webclient/integration/src/app/helpers.tsx b/webclient/integration/src/app/helpers.tsx new file mode 100644 index 000000000..ed4484e3a --- /dev/null +++ b/webclient/integration/src/app/helpers.tsx @@ -0,0 +1,33 @@ +// Shared render helper for the app integration suite. +// +// Two non-obvious choices: +// +// 1. WebClientContext is provided directly (not via production +// ) because the shared integration setup.ts already +// instantiates the WebClient singleton in beforeEach. The production +// provider would `new WebClient(...)` a second time and throw. +// +// 2. We pass the REAL Redux store from @app/store — not renderWithProviders' +// default test-local store. The real WebClient dispatches against the +// real store (that's what createWebClientResponse wires to). Asserting +// against a different in-memory store would silently miss every +// dispatch. setup.ts's resetAll + afterEach clears the real store +// between tests, so each test still starts from a clean slate. + +import { ReactElement } from 'react'; + +import { renderWithProviders } from '../../../src/__test-utils__'; +import { store } from '@app/store'; +import { WebClientContext } from '@app/hooks'; +import { WebClient } from '@app/websocket'; + +export function renderAppScreen(ui: ReactElement) { + return renderWithProviders( + + {ui} + , + { store } + ); +} + +export { store }; diff --git a/webclient/integration/src/app/login-autoconnect.spec.tsx b/webclient/integration/src/app/login-autoconnect.spec.tsx new file mode 100644 index 000000000..8f11c1843 --- /dev/null +++ b/webclient/integration/src/app/login-autoconnect.spec.tsx @@ -0,0 +1,192 @@ +// Full-stack autoconnect integration. Only outbound surfaces are mocked +// (WebSocket via the shared setup, IndexedDB via fake-indexeddb in setup). +// Everything in between — Dexie, DTOs, useSettings/useKnownHosts, useAutoLogin, +// the Login container, WebClient, Redux — runs as shipped code. +// +// We assert auto-login via `connectionAttemptMade` on the real server slice, +// not via the WebSocket mock's call count: KnownHosts fires a testConnection +// on mount for the UX indicator, which also constructs sockets, so raw +// socket counts are noisy. Only the login path dispatches CONNECTION_ATTEMPTED. + +import { act, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Store loads notify React subscribers synchronously when the Dexie +// promise resolves. Awaiting whenReady() directly would let those +// notifications fire outside an act() scope, which trips React's +// "update was not wrapped in act" warning. Wrapping here captures +// both the store resolution and any resulting component re-renders. +const flushStoresAndEffects = async (): Promise => { + await act(async () => { + await settingsStore.whenReady(); + await knownHostsStore.whenReady(); + // Let dependent effects (host-sync, settings-sync) commit. + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +import { autoLoginSession } from '../../../src/hooks/useAutoLogin'; +import { settingsStore } from '../../../src/hooks/useSettings'; +import { knownHostsStore } from '../../../src/hooks/useKnownHosts'; +import Login from '../../../src/containers/Login/Login'; +import { HostDTO, SettingDTO } from '@app/services'; +import { App } from '@app/types'; +import { ServerSelectors, ServerDispatch } from '@app/store'; +import { StatusEnum } from '@app/websocket'; + +import { resetDexie } from '../services/dexie/resetDexie'; +import { renderAppScreen, store } from './helpers'; + +// Mimics the production "user logged out / connection dropped" transition: +// dispatching updateStatus(DISCONNECTED) is what the real reducer uses to +// clear connectionAttemptMade (clearStore intentionally preserves status). +const simulateLogout = () => { + ServerDispatch.updateStatus(StatusEnum.DISCONNECTED, null); +}; + +const seedAutoConnect = async () => { + const setting = new SettingDTO(App.APP_USER); + setting.autoConnect = true; + await setting.save(); + + const id = (await HostDTO.add({ + name: 'Test Server', + host: 'server.example', + port: '4748', + editable: false, + })) as number; + const host = (await HostDTO.get(id))!; + host.remember = true; + host.userName = 'alice'; + host.hashedPassword = 'stored-hash'; + host.lastSelected = true; + await host.save(); +}; + +const attempted = (): boolean => + ServerSelectors.getConnectionAttemptMade(store.getState()); + +afterEach(async () => { + // Absorb any state updates that lingered past the test body (stores + // resolving after unmount, trailing effect commits) so they're wrapped + // in act and don't trip React's warning during teardown. + await flushStoresAndEffects(); +}); + +beforeEach(async () => { + // setup.ts's beforeEach installs fake timers and re-creates the WebClient + // singleton. Dexie + React async need real timers; module caches persist + // across tests and need explicit reset. + vi.useRealTimers(); + await resetDexie(); + + // Reset the module-level caches that load from Dexie. Without this, a + // test would read the PREVIOUS test's snapshot (the Dexie clear only + // truncates storage, not the useSettings / useKnownHosts subscribers' + // cached values). + settingsStore.reset(); + knownHostsStore.reset(); + autoLoginSession.startupCheckRan = false; +}); + +describe('autoconnect — cold start', () => { + it('auto-logs in when Dexie has autoConnect=true + host with stored credentials', async () => { + await seedAutoConnect(); + + renderAppScreen(); + + await waitFor(() => { + expect(attempted()).toBe(true); + }); + }); + + it('does NOT attempt login when Dexie has no settings row', async () => { + renderAppScreen(); + + await flushStoresAndEffects(); + + expect(attempted()).toBe(false); + }); + + it('does NOT attempt login when autoConnect=true but lastSelected host lacks credentials', async () => { + const setting = new SettingDTO(App.APP_USER); + setting.autoConnect = true; + await setting.save(); + await HostDTO.add({ + name: 'Unremembered', + host: 'server.example', + port: '4748', + editable: false, + lastSelected: true, + }); + + renderAppScreen(); + + await flushStoresAndEffects(); + + expect(attempted()).toBe(false); + }); +}); + +describe('autoconnect — logout cycle (same session)', () => { + it('does not auto-reconnect after first auto-login + logout within the same JS session', async () => { + await seedAutoConnect(); + + const first = renderAppScreen(); + await waitFor(() => { + expect(attempted()).toBe(true); + }); + + // Simulate "logged out and returned to /login": unmount, clear the + // store's connectionAttemptMade flag (the app-level equivalent of + // DISCONNECTED → status.connectionAttemptMade reset), remount. + first.unmount(); + simulateLogout(); + + renderAppScreen(); + await flushStoresAndEffects(); + + // The session gate must have kept useAutoLogin silent; the flag stays + // false. + expect(attempted()).toBe(false); + }); + + it('does not auto-connect when the user enabled autoConnect mid-session and then logged out', async () => { + // First mount with autoConnect=false — gate latches without firing. + const first = renderAppScreen(); + await flushStoresAndEffects(); + expect(attempted()).toBe(false); + first.unmount(); + + // Mid-session: user ticked the checkbox → Dexie flipped to autoConnect=true. + await seedAutoConnect(); + + // Remount (post-logout). The gate MUST keep useAutoLogin silent. + renderAppScreen(); + await flushStoresAndEffects(); + + expect(attempted()).toBe(false); + }); +}); + +describe('autoconnect — refresh', () => { + it('auto-connects again after resetting the session gate (page-refresh equivalent)', async () => { + await seedAutoConnect(); + + const first = renderAppScreen(); + await waitFor(() => { + expect(attempted()).toBe(true); + }); + first.unmount(); + + // Simulate a browser refresh: the session gate naturally resets on a + // fresh JS context, and the real connection flag resets too. + simulateLogout(); + autoLoginSession.startupCheckRan = false; + + renderAppScreen(); + await waitFor(() => { + expect(attempted()).toBe(true); + }); + }); +}); diff --git a/webclient/integration/src/helpers/setup.ts b/webclient/integration/src/helpers/setup.ts index 8e4413d98..190f29d97 100644 --- a/webclient/integration/src/helpers/setup.ts +++ b/webclient/integration/src/helpers/setup.ts @@ -9,6 +9,10 @@ import '@testing-library/jest-dom/vitest'; import '../../../src/polyfills'; +// fake-indexeddb polyfills globalThis.indexedDB. MUST be imported before any +// module that opens a Dexie database (Dexie opens on first table access). +// Harmless for the websocket suite, which doesn't touch Dexie. +import 'fake-indexeddb/auto'; import { create } from '@bufbuild/protobuf'; import { afterEach, beforeEach, vi } from 'vitest'; diff --git a/webclient/integration/src/services/dexie/hosts.spec.ts b/webclient/integration/src/services/dexie/hosts.spec.ts new file mode 100644 index 000000000..ffedeb73b --- /dev/null +++ b/webclient/integration/src/services/dexie/hosts.spec.ts @@ -0,0 +1,91 @@ +// Real round-trip tests for HostDTO through Dexie into fake-indexeddb. +// Exercises the full static method surface (add, get, getAll, bulkAdd, +// delete) plus instance save(). + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HostDTO } from '@app/services'; +import type { App } from '@app/types'; + +import { resetDexie } from './resetDexie'; + +const makeRow = (overrides: Partial = {}): App.Host => ({ + name: 'Test', + host: 'host.example', + port: '4747', + editable: false, + ...overrides, +}); + +beforeEach(async () => { + // Shared setup.ts installs fake timers for the websocket suite's + // KeepAliveService; Dexie / fake-indexeddb need real timers. + vi.useRealTimers(); + await resetDexie(); +}); + +describe('HostDTO (real Dexie)', () => { + it('getAll returns empty on a fresh store', async () => { + const all = await HostDTO.getAll(); + expect(all).toEqual([]); + }); + + it('add returns an auto-incremented id and makes the row retrievable by get(id)', async () => { + const id = (await HostDTO.add(makeRow({ name: 'A' }))) as number; + expect(typeof id).toBe('number'); + + const loaded = await HostDTO.get(id); + expect(loaded).toBeDefined(); + expect(loaded!.name).toBe('A'); + expect(loaded!.id).toBe(id); + expect(loaded).toBeInstanceOf(HostDTO); + }); + + it('bulkAdd seeds multiple rows and they are all retrievable via getAll', async () => { + await HostDTO.bulkAdd([ + makeRow({ name: 'A' }), + makeRow({ name: 'B' }), + makeRow({ name: 'C' }), + ]); + + const all = await HostDTO.getAll(); + expect(all.map((h) => h.name).sort()).toEqual(['A', 'B', 'C']); + }); + + it('save() on a loaded instance upserts the same row (does not duplicate)', async () => { + const id = (await HostDTO.add(makeRow({ name: 'A', remember: false }))) as number; + + const loaded = await HostDTO.get(id); + loaded!.remember = true; + loaded!.userName = 'alice'; + loaded!.hashedPassword = 'stored'; + await loaded!.save(); + + const all = await HostDTO.getAll(); + expect(all).toHaveLength(1); + expect(all[0].remember).toBe(true); + expect(all[0].userName).toBe('alice'); + expect(all[0].hashedPassword).toBe('stored'); + }); + + it('delete removes the row by id', async () => { + const idA = (await HostDTO.add(makeRow({ name: 'A' }))) as number; + await HostDTO.add(makeRow({ name: 'B' })); + + await HostDTO.delete(idA as unknown as string); + + const all = await HostDTO.getAll(); + expect(all.map((h) => h.name)).toEqual(['B']); + }); + + it('lastSelected round-trips as a boolean column', async () => { + const idA = (await HostDTO.add(makeRow({ name: 'A', lastSelected: true }))) as number; + await HostDTO.add(makeRow({ name: 'B', lastSelected: false })); + + const all = await HostDTO.getAll(); + const selected = all.find((h) => h.id === idA)!; + expect(selected.lastSelected).toBe(true); + const other = all.find((h) => h.name === 'B')!; + expect(other.lastSelected).toBe(false); + }); +}); diff --git a/webclient/integration/src/services/dexie/resetDexie.ts b/webclient/integration/src/services/dexie/resetDexie.ts new file mode 100644 index 000000000..52ef321b1 --- /dev/null +++ b/webclient/integration/src/services/dexie/resetDexie.ts @@ -0,0 +1,12 @@ +// Clears every table the services suite touches so each test starts from +// empty storage. Dexie is a real singleton, the database a real (fake- +// indexeddb) instance, so state leaks between tests otherwise. + +import { dexieService } from '@app/services'; + +export async function resetDexie(): Promise { + await Promise.all([ + dexieService.settings.clear(), + dexieService.hosts.clear(), + ]); +} diff --git a/webclient/integration/src/services/dexie/settings.spec.ts b/webclient/integration/src/services/dexie/settings.spec.ts new file mode 100644 index 000000000..a3744063f --- /dev/null +++ b/webclient/integration/src/services/dexie/settings.spec.ts @@ -0,0 +1,69 @@ +// Real round-trip tests for SettingDTO through Dexie into fake-indexeddb. +// Nothing is mocked past the IndexedDB boundary — the DTO class, the Dexie +// schema, and the table's put/where/first pipeline all run as shipped code. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SettingDTO } from '@app/services'; +import { App } from '@app/types'; + +import { resetDexie } from './resetDexie'; + +beforeEach(async () => { + // Shared setup.ts installs vi.useFakeTimers() for the websocket suite's + // KeepAliveService needs. Dexie + fake-indexeddb rely on real microtasks + // and will hang under fake timers, so flip back here. + vi.useRealTimers(); + await resetDexie(); +}); + +describe('SettingDTO (real Dexie)', () => { + it('returns undefined for a user with no row yet', async () => { + const loaded = await SettingDTO.get(App.APP_USER); + expect(loaded).toBeUndefined(); + }); + + it('round-trips a fresh setting via save()', async () => { + const dto = new SettingDTO(App.APP_USER); + dto.autoConnect = true; + await dto.save(); + + const loaded = await SettingDTO.get(App.APP_USER); + expect(loaded).toBeDefined(); + expect(loaded!.user).toBe(App.APP_USER); + expect(loaded!.autoConnect).toBe(true); + }); + + it('upserts on repeated save for the same user key', async () => { + const first = new SettingDTO(App.APP_USER); + first.autoConnect = false; + await first.save(); + + const loaded = await SettingDTO.get(App.APP_USER); + loaded!.autoConnect = true; + await loaded!.save(); + + const reloaded = await SettingDTO.get(App.APP_USER); + expect(reloaded!.autoConnect).toBe(true); + }); + + it('matches user lookups case-insensitively (equalsIgnoreCase in DTO.get)', async () => { + const dto = new SettingDTO(App.APP_USER); + await dto.save(); + + const loaded = await SettingDTO.get(App.APP_USER.toUpperCase()); + expect(loaded).toBeDefined(); + expect(loaded!.user).toBe(App.APP_USER); + }); + + it('preserves the SettingDTO class on load (mapToClass binding)', async () => { + const dto = new SettingDTO(App.APP_USER); + await dto.save(); + + const loaded = await SettingDTO.get(App.APP_USER); + expect(loaded).toBeInstanceOf(SettingDTO); + // The save() instance method must be present on the retrieved row so + // call sites (useSettings.update) can round-trip without reinstantiation. + expect(typeof loaded!.save).toBe('function'); + }); +}); diff --git a/webclient/integration/src/admin.spec.ts b/webclient/integration/src/websocket/admin.spec.ts similarity index 92% rename from webclient/integration/src/admin.spec.ts rename to webclient/integration/src/websocket/admin.spec.ts index f67bcfc6f..2c2e02ff1 100644 --- a/webclient/integration/src/admin.spec.ts +++ b/webclient/integration/src/websocket/admin.spec.ts @@ -8,14 +8,14 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { AdminCommands } from '@app/websocket'; -import { connectAndLogin } from './helpers/setup'; +import { connectAndLogin } from '../helpers/setup'; import { buildResponse, buildResponseMessage, buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastAdminCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastAdminCommand } from '../helpers/command-capture'; describe('admin commands', () => { it('adjustMod modifies the user level bitflags on success', () => { diff --git a/webclient/integration/src/authentication.spec.ts b/webclient/integration/src/websocket/authentication.spec.ts similarity index 97% rename from webclient/integration/src/authentication.spec.ts rename to webclient/integration/src/websocket/authentication.spec.ts index c5149c809..2da411aad 100644 --- a/webclient/integration/src/authentication.spec.ts +++ b/webclient/integration/src/websocket/authentication.spec.ts @@ -8,13 +8,13 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; -import { connectAndHandshake, connectAndHandshakeWithSalt } from './helpers/setup'; +import { connectAndHandshake, connectAndHandshakeWithSalt } from '../helpers/setup'; import { buildResponse, buildResponseMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; function makeUser(name: string): Data.ServerInfo_User { return create(Data.ServerInfo_UserSchema, { diff --git a/webclient/integration/src/connection.spec.ts b/webclient/integration/src/websocket/connection.spec.ts similarity index 95% rename from webclient/integration/src/connection.spec.ts rename to webclient/integration/src/websocket/connection.spec.ts index 31cd41b83..2304df399 100644 --- a/webclient/integration/src/connection.spec.ts +++ b/webclient/integration/src/websocket/connection.spec.ts @@ -9,7 +9,7 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { StatusEnum } from '@app/websocket'; -import { PROTOCOL_VERSION } from '../../src/websocket/config'; +import { PROTOCOL_VERSION } from '../../../src/websocket/config'; import { getMockWebSocket, @@ -17,14 +17,14 @@ import { openMockWebSocket, setPendingOptions, connectAndHandshake, -} from './helpers/setup'; +} from '../helpers/setup'; import type { WebSocketConnectOptions } from '@app/websocket'; import { WebSocketConnectReason } from '@app/websocket'; import { buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebSocketConnectOptions { return { diff --git a/webclient/integration/src/deck.spec.ts b/webclient/integration/src/websocket/deck.spec.ts similarity index 95% rename from webclient/integration/src/deck.spec.ts rename to webclient/integration/src/websocket/deck.spec.ts index 76ed032be..a7a8da0eb 100644 --- a/webclient/integration/src/deck.spec.ts +++ b/webclient/integration/src/websocket/deck.spec.ts @@ -8,13 +8,13 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { SessionCommands } from '@app/websocket'; -import { connectAndLogin } from './helpers/setup'; +import { connectAndLogin } from '../helpers/setup'; import { buildResponse, buildResponseMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; describe('deck operations', () => { it('populates backendDecks from deckList response', () => { diff --git a/webclient/integration/src/game.spec.ts b/webclient/integration/src/websocket/game.spec.ts similarity index 98% rename from webclient/integration/src/game.spec.ts rename to webclient/integration/src/websocket/game.spec.ts index eb88e6b2b..4775b5332 100644 --- a/webclient/integration/src/game.spec.ts +++ b/webclient/integration/src/websocket/game.spec.ts @@ -8,7 +8,7 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { GameCommands, RoomCommands } from '@app/websocket'; -import { connectAndHandshake, connectAndLogin } from './helpers/setup'; +import { connectAndHandshake, connectAndLogin } from '../helpers/setup'; import { buildResponse, buildResponseMessage, @@ -16,8 +16,8 @@ import { buildRoomEventMessage, buildGameEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from '../helpers/command-capture'; function joinGame(gameId: number): void { deliverMessage(buildSessionEventMessage( diff --git a/webclient/integration/src/keep-alive.spec.ts b/webclient/integration/src/websocket/keep-alive.spec.ts similarity index 92% rename from webclient/integration/src/keep-alive.spec.ts rename to webclient/integration/src/websocket/keep-alive.spec.ts index c4889e0fc..90ee634e0 100644 --- a/webclient/integration/src/keep-alive.spec.ts +++ b/webclient/integration/src/websocket/keep-alive.spec.ts @@ -6,13 +6,13 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { StatusEnum } from '@app/websocket'; -import { connectRaw, getMockWebSocket } from './helpers/setup'; +import { connectRaw, getMockWebSocket } from '../helpers/setup'; import { buildResponse, buildResponseMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; describe('keep-alive', () => { it('sends a Command_Ping on every keepalive interval tick', () => { diff --git a/webclient/integration/src/moderator.spec.ts b/webclient/integration/src/websocket/moderator.spec.ts similarity index 95% rename from webclient/integration/src/moderator.spec.ts rename to webclient/integration/src/websocket/moderator.spec.ts index c90c3b026..088d254dc 100644 --- a/webclient/integration/src/moderator.spec.ts +++ b/webclient/integration/src/websocket/moderator.spec.ts @@ -9,13 +9,13 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { ModeratorCommands } from '@app/websocket'; -import { connectAndLogin } from './helpers/setup'; +import { connectAndLogin } from '../helpers/setup'; import { buildResponse, buildResponseMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastModeratorCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastModeratorCommand } from '../helpers/command-capture'; describe('moderator commands', () => { it('getBanHistory populates server.banHistory on success', () => { diff --git a/webclient/integration/src/password-reset.spec.ts b/webclient/integration/src/websocket/password-reset.spec.ts similarity index 94% rename from webclient/integration/src/password-reset.spec.ts rename to webclient/integration/src/websocket/password-reset.spec.ts index ec842c3ec..998390304 100644 --- a/webclient/integration/src/password-reset.spec.ts +++ b/webclient/integration/src/websocket/password-reset.spec.ts @@ -8,13 +8,13 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; -import { connectAndHandshake } from './helpers/setup'; +import { connectAndHandshake } from '../helpers/setup'; import { buildResponse, buildResponseMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; describe('password reset', () => { it('forgotPasswordRequest sends command and disconnects on success', () => { diff --git a/webclient/integration/src/rooms.spec.ts b/webclient/integration/src/websocket/rooms.spec.ts similarity index 98% rename from webclient/integration/src/rooms.spec.ts rename to webclient/integration/src/websocket/rooms.spec.ts index 5bd776d08..77236d8c7 100644 --- a/webclient/integration/src/rooms.spec.ts +++ b/webclient/integration/src/websocket/rooms.spec.ts @@ -8,15 +8,15 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { RoomCommands } from '@app/websocket'; -import { connectAndHandshake } from './helpers/setup'; +import { connectAndHandshake } from '../helpers/setup'; import { buildResponse, buildResponseMessage, buildRoomEventMessage, buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from '../helpers/command-capture'; import { fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf'; function makeRoom(overrides: Partial<{ diff --git a/webclient/integration/src/server-events.spec.ts b/webclient/integration/src/websocket/server-events.spec.ts similarity index 97% rename from webclient/integration/src/server-events.spec.ts rename to webclient/integration/src/websocket/server-events.spec.ts index 16ac3a6bf..a0eefebf1 100644 --- a/webclient/integration/src/server-events.spec.ts +++ b/webclient/integration/src/websocket/server-events.spec.ts @@ -8,11 +8,11 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { StatusEnum } from '@app/websocket'; -import { connectAndHandshake } from './helpers/setup'; +import { connectAndHandshake } from '../helpers/setup'; import { buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; +} from '../helpers/protobuf-builders'; describe('server events', () => { it('writes the server banner into server.info.message on Event_ServerMessage', () => { diff --git a/webclient/integration/src/users.spec.ts b/webclient/integration/src/websocket/users.spec.ts similarity index 95% rename from webclient/integration/src/users.spec.ts rename to webclient/integration/src/websocket/users.spec.ts index 36062963d..e1cc5777a 100644 --- a/webclient/integration/src/users.spec.ts +++ b/webclient/integration/src/websocket/users.spec.ts @@ -6,14 +6,14 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { connectAndLogin } from './helpers/setup'; +import { connectAndLogin } from '../helpers/setup'; import { buildResponse, buildResponseMessage, buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; function makeUser(name: string): Data.ServerInfo_User { return create(Data.ServerInfo_UserSchema, { diff --git a/webclient/package-lock.json b/webclient/package-lock.json index ed7f1854a..8697b0d3e 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -57,6 +57,7 @@ "eslint": "^10.2.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-boundaries": "^6.0.2", + "fake-indexeddb": "^6.2.5", "fs-extra": "^11.3.4", "globals": "^17.5.0", "husky": "^9.1.7", @@ -3372,6 +3373,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/webclient/package.json b/webclient/package.json index 774cef831..422b2e517 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -71,6 +71,7 @@ "eslint": "^10.2.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-boundaries": "^6.0.2", + "fake-indexeddb": "^6.2.5", "fs-extra": "^11.3.4", "globals": "^17.5.0", "husky": "^9.1.7", diff --git a/webclient/src/__test-utils__/renderWithProviders.tsx b/webclient/src/__test-utils__/renderWithProviders.tsx index c7ae2d242..4d0546083 100644 --- a/webclient/src/__test-utils__/renderWithProviders.tsx +++ b/webclient/src/__test-utils__/renderWithProviders.tsx @@ -14,12 +14,17 @@ import { actionReducer } from '../store/actions'; import { ToastProvider } from '../components/Toast/ToastContext'; import type { RootState } from '../store/store'; -// Minimal i18n instance for tests — returns keys as-is. +// Minimal i18n instance for tests — returns keys as-is. A non-empty +// `resources` entry is required so i18next registers `en-US` as a known +// language; otherwise `i18n.resolvedLanguage` stays `undefined`, which +// LanguageDropdown seeds into a MUI Select and MUI warns "out-of-range +// value `undefined`". Value is an empty translation map, since tests +// already assert on i18n keys directly. const testI18n = i18n.createInstance(); testI18n.use(initReactI18next).init({ - lng: 'en', - resources: {}, - fallbackLng: 'en', + lng: 'en-US', + resources: { 'en-US': { translation: {} } }, + fallbackLng: 'en-US', interpolation: { escapeValue: false }, }); diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index c16c4f67a..416de9875 100644 --- a/webclient/src/components/KnownHosts/KnownHosts.tsx +++ b/webclient/src/components/KnownHosts/KnownHosts.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { Select, MenuItem } from '@mui/material'; @@ -13,10 +13,9 @@ import AddIcon from '@mui/icons-material/Add'; import EditRoundedIcon from '@mui/icons-material/Edit'; import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; -import { useWebClient } from '@app/hooks'; +import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; import { KnownHostDialog } from '@app/dialogs'; -import { useReduxEffect } from '@app/hooks'; -import { DefaultHosts, HostDTO, getHostPort } from '@app/services'; +import { getHostPort, HostDTO } from '@app/services'; import { ServerTypes } from '@app/store'; import { App } from '@app/types'; import Toast from '../Toast/Toast'; @@ -32,244 +31,215 @@ enum TestConnection { const PREFIX = 'KnownHosts'; const classes = { - root: `${PREFIX}-root` + root: `${PREFIX}-root`, }; const Root = styled('div')(({ theme }) => ({ [`&.${classes.root}`]: { '& .KnownHosts-error': { - color: theme.palette.error.main + color: theme.palette.error.main, }, '& .KnownHosts-warning': { - color: theme.palette.warning.main + color: theme.palette.warning.main, }, '& .KnownHosts-item': { [`& .${TestConnection.TESTING}`]: { - color: theme.palette.warning.main + color: theme.palette.warning.main, }, [`& .${TestConnection.FAILED}`]: { - color: theme.palette.error.main + color: theme.palette.error.main, }, [`& .${TestConnection.SUCCESS}`]: { - color: theme.palette.success.main - } - } - } + color: theme.palette.success.main, + }, + }, + }, })); - -const KnownHosts = (props) => { - const { input: { onChange }, meta, disabled } = props; +const KnownHosts = (props: any) => { + const { input, meta, disabled } = props; + const onChange: (value: HostDTO) => void = input.onChange; const { touched, error, warning } = meta; const { t } = useTranslation(); const webClient = useWebClient(); + const knownHosts = useKnownHosts(); - const [hostsState, setHostsState] = useState({ - hosts: [], - selectedHost: {} as any, - }); - - const [dialogState, setDialogState] = useState({ + const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({ open: false, edit: null, }); - const [testingConnection, setTestingConnection] = useState(null); + const [testingConnection, setTestingConnection] = useState(null); const [showCreateToast, setShowCreateToast] = useState(false); const [showDeleteToast, setShowDeleteToast] = useState(false); const [showEditToast, setShowEditToast] = useState(false); - const loadKnownHosts = useCallback(async () => { - const hosts = await HostDTO.getAll(); + const selectedHost = + knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined; + const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : []; - if (!hosts?.length) { - // @TODO: find a better pattern to seeding default data in indexedDB - await HostDTO.bulkAdd(DefaultHosts); - loadKnownHosts(); - } else { - const selectedHost = hosts.find(({ lastSelected }) => lastSelected) || hosts[0]; - setHostsState(s => ({ ...s, hosts, selectedHost })); - } - }, []); + const testConnection = (host: HostDTO) => { + setTestingConnection(TestConnection.TESTING); + webClient.request.authentication.testConnection({ ...getHostPort(host) }); + }; + // Mirror the store's selectedHost into the form field. Also kick off a + // connection test so the user sees the green/red indicator on mount. useEffect(() => { - loadKnownHosts(); - }, [loadKnownHosts]); - - useEffect(() => { - const { selectedHost } = hostsState; - - if (selectedHost?.id) { - updateLastSelectedHost(selectedHost.id).then(() => { - onChange(selectedHost); - }); + if (!selectedHost) { + return; } - }, [hostsState, onChange]); + onChange(selectedHost); + testConnection(selectedHost); + }, [selectedHost]); - useReduxEffect(() => { - setTestingConnection(TestConnection.SUCCESS); - }, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []); + useReduxEffect( + () => { + setTestingConnection(TestConnection.SUCCESS); + }, + ServerTypes.TEST_CONNECTION_SUCCESSFUL, + [] + ); - useReduxEffect(() => { - setTestingConnection(TestConnection.FAILED); - }, ServerTypes.TEST_CONNECTION_FAILED, []); + useReduxEffect( + () => { + setTestingConnection(TestConnection.FAILED); + }, + ServerTypes.TEST_CONNECTION_FAILED, + [] + ); - const selectHost = (selectedHost) => { - setHostsState(s => ({ ...s, selectedHost })); + const onPick = async (host: HostDTO) => { + if (knownHosts.status !== LoadingState.READY) { + return; + } + onChange(host); + await knownHosts.select(host.id!); + testConnection(host); }; const openAddKnownHostDialog = () => { - setDialogState(s => ({ ...s, open: true, edit: null })); + setDialogState((s) => ({ ...s, open: true, edit: null })); }; const openEditKnownHostDialog = (host: HostDTO) => { - setDialogState(s => ({ ...s, open: true, edit: host })); + setDialogState((s) => ({ ...s, open: true, edit: host })); }; const closeKnownHostDialog = () => { - setDialogState(s => ({ ...s, open: false })); - } - - const handleDialogRemove = async ({ id }) => { - setHostsState(s => ({ - ...s, - hosts: s.hosts.filter(host => host.id !== id), - selectedHost: s.selectedHost.id === id ? s.hosts[0] : s.selectedHost, - })); - - closeKnownHostDialog(); - HostDTO.delete(id); - setShowDeleteToast(true) + setDialogState((s) => ({ ...s, open: false })); }; - const handleDialogSubmit = async ({ id, name, host, port }) => { - if (id) { - const hostDTO = await HostDTO.get(id); - hostDTO.name = name; - hostDTO.host = host; - hostDTO.port = port; - await hostDTO.save(); + const handleDialogRemove = async ({ id }: { id: number }) => { + if (knownHosts.status !== LoadingState.READY) { + return; + } + await knownHosts.remove(id); + closeKnownHostDialog(); + setShowDeleteToast(true); + }; - setHostsState(s => ({ - ...s, - hosts: s.hosts.map(h => h.id === id ? hostDTO : h), - selectedHost: hostDTO - })); - setShowEditToast(true) + const handleDialogSubmit = async ({ + id, + name, + host, + port, + }: { + id?: number; + name: string; + host: string; + port: string; + }) => { + if (knownHosts.status !== LoadingState.READY) { + return; + } + + if (id) { + await knownHosts.update(id, { name, host, port }); + setShowEditToast(true); } else { const newHost: App.Host = { name, host, port, editable: true }; - newHost.id = await HostDTO.add(newHost) as number; - - setHostsState(s => ({ - ...s, - hosts: [...s.hosts, newHost], - selectedHost: newHost, - })); - setShowCreateToast(true) + await knownHosts.add(newHost); + setShowCreateToast(true); } closeKnownHostDialog(); }; - const updateLastSelectedHost = (hostId): Promise => { - testConnection(); - - return HostDTO.getAll().then(hosts => - hosts.map(async host => { - if (host.id === hostId) { - host.lastSelected = true; - return await host.save(); - } - - if (host.lastSelected) { - host.lastSelected = false; - return await host.save(); - } - - return host; - }) - ); - }; - - const testConnection = () => { - setTestingConnection(TestConnection.TESTING); - - const options = { ...getHostPort(hostsState.selectedHost) }; - webClient.request.authentication.testConnection(options); - } - return ( - - { touched && ( -
- { - (error && -
- {error} - -
- ) || - - (warning &&
{warning}
) - } + + {touched && ( +
+ {(error && ( +
+ {error} + +
+ )) || + (warning &&
{warning}
)}
- ) } + )} - { t('KnownHosts.label') } + {t('KnownHosts.label')}
@@ -280,9 +250,15 @@ const KnownHosts = (props) => { onSubmit={handleDialogSubmit} handleClose={closeKnownHostDialog} /> - setShowCreateToast(false)}>{ t('KnownHosts.toast', { mode: 'created' }) } - setShowDeleteToast(false)}>{ t('KnownHosts.toast', { mode: 'deleted' }) } - setShowEditToast(false)}>{ t('KnownHosts.toast', { mode: 'edited' }) } + setShowCreateToast(false)}> + {t('KnownHosts.toast', { mode: 'created' })} + + setShowDeleteToast(false)}> + {t('KnownHosts.toast', { mode: 'deleted' })} + + setShowEditToast(false)}> + {t('KnownHosts.toast', { mode: 'edited' })} + ); }; diff --git a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx index d48aa2e7b..5043ca36c 100644 --- a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx +++ b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx @@ -11,7 +11,12 @@ import './LanguageDropdown.css'; const LanguageDropdown = () => { const { t, i18n } = useTranslation(); - const [language, setLanguage] = useState(i18n.resolvedLanguage); + // `resolvedLanguage` can be undefined when i18next hasn't matched the + // active lng against any registered resource yet — most often at the + // first render in tests with a minimal i18n instance. Fall back to + // `i18n.language` (always set to whatever was passed to init) and then + // to empty string so MUI's Select has a concrete, in-range value. + const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? ''); useEffect(() => { if (language !== i18n.resolvedLanguage) { diff --git a/webclient/src/containers/Login/Login.spec.tsx b/webclient/src/containers/Login/Login.spec.tsx new file mode 100644 index 000000000..4f59e37b1 --- /dev/null +++ b/webclient/src/containers/Login/Login.spec.tsx @@ -0,0 +1,225 @@ +/** + * Login auto-connect integration tests. + * + * Exercises the full wire from `useAutoLogin` through the Login container + * into `webClient.request.authentication.login`. Scenarios mirror the user- + * visible cycles we care about: + * - cold start with / without auto-connect + * - logout within the same session must NOT re-auto-connect + * - page refresh (fresh JS context) resets the gate + * + * The startup-check gate lives on the `autoLoginSession` object exported by + * `useAutoLogin.ts`. Tests flip it back to false in `beforeEach` to stand in + * for a page refresh between scenarios. `vi.resetModules()` would be the + * natural equivalent but is prohibitively slow in the full suite because it + * forces every imported module to re-evaluate. + */ + +import { act, waitFor } from '@testing-library/react'; + +import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__'; + +// Lets pending microtasks resolve inside an act() scope so that the state +// updates they trigger (useFormState subscribers, useFireOnce state, etc.) +// are captured. Without this, useAutoLogin's Promise.all resolves *after* +// render returns, and React warns "update ... was not wrapped in act". +const flushEffects = async (): Promise => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; +import { makeSettings, makeSettingsHook } from '../../hooks/__mocks__/useSettings'; +import { makeHost, makeKnownHostsHook } from '../../hooks/__mocks__/useKnownHosts'; +import { autoLoginSession } from '../../hooks/useAutoLogin'; +import { LoadingState } from '@app/hooks'; +import Login from './Login'; + +const hoisted = vi.hoisted(() => ({ + mockWebClient: undefined as any, + getSettings: vi.fn(), + getKnownHosts: vi.fn(), + useSettings: vi.fn(), + useKnownHosts: vi.fn(), +})); + +vi.mock('../../hooks/useSettings', () => ({ + useSettings: hoisted.useSettings, + getSettings: hoisted.getSettings, +})); +vi.mock('../../hooks/useKnownHosts', () => ({ + useKnownHosts: hoisted.useKnownHosts, + getKnownHosts: hoisted.getKnownHosts, +})); +vi.mock('../../hooks/useWebClient', () => ({ + useWebClient: () => hoisted.mockWebClient, + WebClientProvider: ({ children }: { children: any }) => children, +})); + +beforeAll(() => { + const client = createMockWebClient(); + (client.request.authentication as any).testConnection = vi.fn(); + hoisted.mockWebClient = client; +}); + +afterEach(async () => { + // Absorb any state updates that lingered past the test body (e.g. + // useAutoLogin's Promise.all resolving a moment too late) so they're + // wrapped in act and don't trip React's warning during teardown. + await flushEffects(); +}); + +beforeEach(() => { + // "Page refresh" between tests: reset the session gate that useAutoLogin + // uses to prevent re-firing within a JS session. Production code only + // writes this flag once, from inside the startup effect; tests flip it + // back to false here to simulate a fresh browser tab. + autoLoginSession.startupCheckRan = false; + + // clearAllMocks in the global afterEach only clears call history; mock + // implementations (mockResolvedValue, mockReturnValue) persist. Reset + // them explicitly so a previous test's arming doesn't leak into this one. + hoisted.getSettings.mockReset(); + hoisted.getKnownHosts.mockReset(); + hoisted.useSettings.mockReset(); + hoisted.useKnownHosts.mockReset(); + + const defaultHost = makeHost({ + id: 1, + remember: true, + userName: 'alice', + hashedPassword: 'stored-hash', + lastSelected: true, + }); + + hoisted.useSettings.mockReturnValue( + makeSettingsHook({ + status: LoadingState.READY, + value: makeSettings({ autoConnect: false }), + update: vi.fn().mockResolvedValue(undefined), + }) + ); + hoisted.useKnownHosts.mockReturnValue( + makeKnownHostsHook({ + status: LoadingState.READY, + value: { hosts: [defaultHost], selectedHost: defaultHost }, + }) + ); + hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: false })); + hoisted.getKnownHosts.mockResolvedValue({ + hosts: [defaultHost], + selectedHost: defaultHost, + }); +}); + +const armAutoConnect = () => { + const host = makeHost({ + id: 1, + remember: true, + userName: 'alice', + hashedPassword: 'stored-hash', + lastSelected: true, + }); + hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true })); + hoisted.getKnownHosts.mockResolvedValue({ hosts: [host], selectedHost: host }); +}; + +describe('Login — auto-connect cold start', () => { + test('fires login when settings + host say go', async () => { + armAutoConnect(); + + renderWithProviders(, { preloadedState: disconnectedState }); + + await waitFor(() => { + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + expect(hoisted.mockWebClient.request.authentication.login.mock.calls[0][0]).toMatchObject({ + userName: 'alice', + hashedPassword: 'stored-hash', + }); + }); + + test('does not fire when autoConnect setting is off', async () => { + renderWithProviders(, { preloadedState: disconnectedState }); + + await flushEffects(); + expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); + }); + + test('does not fire when selected host has no stored credentials', async () => { + const host = makeHost({ + id: 1, + remember: false, + userName: undefined, + hashedPassword: undefined, + lastSelected: true, + }); + hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true })); + hoisted.getKnownHosts.mockResolvedValue({ hosts: [host], selectedHost: host }); + + renderWithProviders(, { preloadedState: disconnectedState }); + + await flushEffects(); + expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); + }); +}); + +describe('Login — logout cycle (same JS session)', () => { + test('does not re-auto-connect after first auto-login + logout', async () => { + armAutoConnect(); + + // First mount: Login appears, useAutoLogin fires login. + const first = renderWithProviders(, { preloadedState: disconnectedState }); + await waitFor(() => { + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + + // Simulate arriving at /server and then logging out: Login unmounts, + // then a fresh Login mounts again with disconnected state. + first.unmount(); + renderWithProviders(, { preloadedState: disconnectedState }); + + await flushEffects(); + + // No second login call — the session gate is latched. + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + + test('does not auto-connect when user enabled autoConnect mid-session and then logged out', async () => { + // Scenario: user manually logs in with autoConnect=false. They tick the + // auto-connect checkbox during that session (the setting flips true). + // They log out. Returning to /login must NOT auto-connect — the setting + // change was a preference for NEXT launch, not a signal to log in. + + // First mount: autoConnect=false, so the startup check runs and finds + // nothing to do. The gate latches anyway. + const first = renderWithProviders(, { preloadedState: disconnectedState }); + await flushEffects(); + expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); + + first.unmount(); + + // Mid-session, user ticked the checkbox. Future getSettings resolves + // return the new value, but the session gate prevents a re-check. + hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true })); + + renderWithProviders(, { preloadedState: disconnectedState }); + await flushEffects(); + expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); + }); +}); + +describe('Login — refresh cycle', () => { + // `beforeEach` flips autoLoginSession.startupCheckRan back to false, which + // stands in for a page refresh. This test just re-asserts the positive + // case: a refresh re-enables auto-connect when the persisted preference + // still says yes. + test('a fresh session gate re-fires auto-login when conditions still hold', async () => { + armAutoConnect(); + + renderWithProviders(, { preloadedState: disconnectedState }); + + await waitFor(() => { + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index cf0a20420..be097a7b8 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { Navigate } from 'react-router-dom'; @@ -9,9 +9,9 @@ import Typography from '@mui/material/Typography'; import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs'; import { LanguageDropdown } from '@app/components'; import { LoginForm } from '@app/forms'; -import { useReduxEffect, useFireOnce, useWebClient } from '@app/hooks'; +import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; import { Images } from '@app/images'; -import { HostDTO, getHostPort, serverProps } from '@app/services'; +import { getHostPort, serverProps } from '@app/services'; import { App, Enriched } from '@app/types'; import { ServerSelectors, ServerTypes } from '@app/store'; import Layout from '../Layout/Layout'; @@ -66,12 +66,14 @@ const Root = styled('div')(({ theme }) => ({ const Login = () => { const description = useAppSelector(s => ServerSelectors.getDescription(s)); const isConnected = useAppSelector(ServerSelectors.getIsConnected); + const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade); const webClient = useWebClient(); const { t } = useTranslation(); const [pendingActivationOptions, setPendingActivationOptions] = useState(null); - const [rememberLogin, setRememberLogin] = useState(null); + const rememberLoginRef = useRef(null); + const knownHosts = useKnownHosts(); const [dialogState, setDialogState] = useState({ passwordResetRequestDialog: false, resetPasswordDialog: false, @@ -113,15 +115,17 @@ const Login = () => { }, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []); useReduxEffect(({ payload: { options: { hashedPassword } } }) => { - updateHost(hashedPassword, rememberLogin); - }, ServerTypes.LOGIN_SUCCESSFUL, [rememberLogin]); + if (rememberLoginRef.current) { + updateHost(hashedPassword, rememberLoginRef.current); + } + }, ServerTypes.LOGIN_SUCCESSFUL, []); const showDescription = () => { return !isConnected && description?.length; }; const onSubmitLogin = useCallback((loginForm) => { - setRememberLogin(loginForm); + rememberLoginRef.current = loginForm; const { userName, password, selectedHost, remember } = loginForm; const options: Omit = { @@ -139,18 +143,18 @@ const Login = () => { const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin); - const updateHost = (hashedPassword, { selectedHost, remember, userName }) => { - HostDTO.get(selectedHost.id).then(hostDTO => { - hostDTO.remember = remember; - hostDTO.userName = remember ? userName : null; - hostDTO.hashedPassword = remember ? hashedPassword : null; + useAutoLogin(handleLogin, connectionAttemptMade); - hostDTO.save(); + const updateHost = (hashedPassword, { selectedHost, remember, userName }) => { + knownHosts.update(selectedHost.id, { + remember, + userName: remember ? userName : null, + hashedPassword: remember ? hashedPassword : null, }); }; const handleRegistrationDialogSubmit = (registerForm) => { - setRememberLogin(registerForm); + rememberLoginRef.current = registerForm; const { userName, password, email, country, realName, selectedHost } = registerForm; webClient.request.authentication.register({ diff --git a/webclient/src/forms/LoginForm/LoginForm.spec.tsx b/webclient/src/forms/LoginForm/LoginForm.spec.tsx new file mode 100644 index 000000000..ed6178eec --- /dev/null +++ b/webclient/src/forms/LoginForm/LoginForm.spec.tsx @@ -0,0 +1,100 @@ +import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__'; +import { makeSettingsHook, makeSettings } from '../../hooks/__mocks__/useSettings'; +import { makeKnownHostsHook, makeHost } from '../../hooks/__mocks__/useKnownHosts'; + +const hoisted = vi.hoisted(() => ({ + mockWebClient: undefined as any, + mockUseSettings: vi.fn(), + mockUseKnownHosts: vi.fn(), +})); + +vi.mock('@app/hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useWebClient: () => hoisted.mockWebClient, + useSettings: hoisted.mockUseSettings, + useKnownHosts: hoisted.mockUseKnownHosts, + }; +}); + +import LoginForm from './LoginForm'; +import { LoadingState } from '@app/hooks'; + +beforeAll(() => { + const client = createMockWebClient(); + (client.request.authentication as any).testConnection = vi.fn(); + hoisted.mockWebClient = client; +}); + +describe('LoginForm — regression: settings.autoConnect is not clobbered by host state', () => { + test('selecting a host with remember=false does NOT call settings.update', () => { + const update = vi.fn().mockResolvedValue(undefined); + + hoisted.mockUseSettings.mockReturnValue( + makeSettingsHook({ + status: LoadingState.READY, + value: makeSettings({ autoConnect: true }), + update, + }) + ); + + const host = makeHost({ + id: 1, + remember: false, + userName: undefined, + hashedPassword: undefined, + lastSelected: true, + }); + hoisted.mockUseKnownHosts.mockReturnValue( + makeKnownHostsHook({ + status: LoadingState.READY, + value: { hosts: [host], selectedHost: host }, + }) + ); + + renderWithProviders( + , + { preloadedState: disconnectedState } + ); + + // After mount + all host-sync effects settle, the form has updated its + // local fields to reflect the selected host. What MUST NOT happen is a + // write to the persisted autoConnect setting. + expect(update).not.toHaveBeenCalled(); + }); + + test('auto-login never fires from the form; that is now the container concern', () => { + const onSubmit = vi.fn(); + const update = vi.fn().mockResolvedValue(undefined); + + hoisted.mockUseSettings.mockReturnValue( + makeSettingsHook({ + status: LoadingState.READY, + value: makeSettings({ autoConnect: true }), + update, + }) + ); + + const host = makeHost({ + id: 1, + remember: true, + userName: 'joe', + hashedPassword: 'abc', + lastSelected: true, + }); + hoisted.mockUseKnownHosts.mockReturnValue( + makeKnownHostsHook({ + status: LoadingState.READY, + value: { hosts: [host], selectedHost: host }, + }) + ); + + renderWithProviders( + , + { preloadedState: disconnectedState } + ); + + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/forms/LoginForm/LoginForm.tsx b/webclient/src/forms/LoginForm/LoginForm.tsx index f46764126..8323989b9 100644 --- a/webclient/src/forms/LoginForm/LoginForm.tsx +++ b/webclient/src/forms/LoginForm/LoginForm.tsx @@ -1,29 +1,192 @@ import React, { useEffect, useState } from 'react'; -import { Form, Field } from 'react-final-form'; +import { Form, Field, useFormState, FormApi } from 'react-final-form'; import { OnChange } from 'react-final-form-listeners'; import { useTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { CheckboxField, InputField, KnownHosts } from '@app/components'; -import { useAutoConnect } from '@app/hooks'; -import { HostDTO, SettingDTO } from '@app/services'; -import { App } from '@app/types'; -import { useAppSelector, ServerSelectors } from '@app/store'; +import { LoadingState, useKnownHosts, useSettings } from '@app/hooks'; import './LoginForm.css'; -const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginFormProps) => { +interface LoginFormProps { + onSubmit: (values: any) => void; + disableSubmitButton: boolean; + onResetPassword: () => void; +} + +interface LoginFormBodyProps extends LoginFormProps { + form: FormApi; + handleSubmit: (event?: React.SyntheticEvent) => void; +} + +const LoginFormBody = ({ + form, + handleSubmit, + disableSubmitButton, + onResetPassword, +}: LoginFormBodyProps) => { const { t } = useTranslation(); const PASSWORD_LABEL = t('Common.label.password'); const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`; - const [host, setHost] = useState(null); - const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false); - const [autoConnect, setAutoConnect] = useAutoConnect(); - const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade); + const settings = useSettings(); + const hosts = useKnownHosts(); + const { values } = useFormState(); - const validate = values => { + const selectedHost = hosts.status === LoadingState.READY ? hosts.value?.selectedHost : undefined; + + const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false); + const [storedHashInvalidated, setStoredHashInvalidated] = useState(false); + + const canUseStoredPassword = (remember: boolean, password: string | undefined) => + Boolean(remember && selectedHost?.hashedPassword && !password && !storedHashInvalidated); + + const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on); + + // Host-sync: when the selected host changes, mirror its username + stored- + // password hint into the form. Deliberately does NOT touch autoConnect — the + // persisted setting is decoupled from which host is currently picked. + useEffect(() => { + if (!selectedHost) { + return; + } + + form.change('userName', selectedHost.userName); + form.change('password', ''); + form.change('remember', Boolean(selectedHost.remember)); + + setStoredHashInvalidated(false); + togglePasswordLabel( + Boolean(selectedHost.remember && selectedHost.hashedPassword) + ); + }, [selectedHost, form]); + + // Mirror the persisted autoConnect setting into the form checkbox so the + // field reflects truth as soon as settings load. + useEffect(() => { + if (settings.status !== LoadingState.READY) { + return; + } + form.change('autoConnect', settings.value?.autoConnect); + }, [settings, form]); + + const onUserNameChange = (userName: string | undefined) => { + const fieldChanged = selectedHost?.userName?.toLowerCase() !== userName?.toLowerCase(); + if (canUseStoredPassword(values.remember, values.password) && fieldChanged) { + setStoredHashInvalidated(true); + } + }; + + const onRememberChange = (checked: boolean) => { + // When the user unchecks "remember password", the auto-connect checkbox + // can't meaningfully stay on (there are no saved credentials to use), so + // reflect that in the form UI. Note: this writes only to the form field, + // NOT to the persisted setting — toggling host-level remember is not a + // user intent to change the app-level auto-connect preference. + if (!checked && values.autoConnect) { + form.change('autoConnect', false); + } + + togglePasswordLabel(canUseStoredPassword(checked, values.password)); + }; + + // User-initiated toggle of the auto-connect checkbox. This is the ONLY path + // that writes to the persisted setting — wired directly to the Checkbox's + // native onChange (see JSX below), not to a listener, because + // OnChange fires on programmatic form.change calls too (host-sync effects + // etc.) and would leak those into Dexie. + const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => { + fieldOnChange(checked); + + if (settings.status === LoadingState.READY) { + void settings.update({ autoConnect: checked }); + } + + if (checked && !values.remember) { + form.change('remember', true); + } + }; + + return ( +
+
+
+ + {onUserNameChange} +
+
+ setUseStoredPasswordLabel(false)} + onBlur={() => + togglePasswordLabel(canUseStoredPassword(values.remember, values.password)) + } + name="password" + type="password" + component={InputField} + autoComplete="new-password" + /> +
+
+ + {onRememberChange} + + +
+
+ +
+
+ + {({ input }) => ( + onUserToggleAutoConnect(checked, input.onChange)} + color="primary" + /> + } + /> + )} + +
+
+ +
+ ); +}; + +const LoginForm = (props: LoginFormProps) => { + const { t } = useTranslation(); + + const validate = (values: any) => { const errors: any = {}; if (!values.userName) { @@ -34,137 +197,20 @@ const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginForm } return errors; - } - - const useStoredPassword = (remember, password) => remember && host?.hashedPassword && !password; - const togglePasswordLabel = (useStoredLabel) => { - setUseStoredPasswordLabel(useStoredLabel); }; - const handleOnSubmit = ({ userName, ...values }) => { + const handleOnSubmit = ({ userName, ...values }: any) => { userName = userName?.trim(); - onSubmit({ userName, ...values }); - } + props.onSubmit({ userName, ...values }); + }; return (
- {({ handleSubmit, form }) => { - const { values } = form.getState(); - - useEffect(() => { - SettingDTO.get(App.APP_USER).then((userSetting: SettingDTO) => { - if (userSetting?.autoConnect && !connectionAttemptMade) { - HostDTO.getAll().then(hosts => { - let lastSelectedHost = hosts.find(({ lastSelected }) => lastSelected); - - if (lastSelectedHost?.remember && lastSelectedHost?.hashedPassword) { - togglePasswordLabel(true); - - form.change('selectedHost', lastSelectedHost); - form.change('userName', lastSelectedHost.userName); - form.change('remember', true); - form.submit(); - } - }); - } - }); - }, []); - - useEffect(() => { - if (!host) { - return; - } - - form.change('userName', host.userName); - form.change('password', ''); - - onRememberChange(host.remember); - onAutoConnectChange(host.remember && autoConnect); - togglePasswordLabel(useStoredPassword(host.remember, values.password)); - }, [host]); - - const onUserNameChange = (userName) => { - const fieldChanged = host?.userName?.toLowerCase() !== values.userName?.toLowerCase(); - if (useStoredPassword(values.remember, values.password) && fieldChanged) { - setHost(({ hashedPassword: _hashedPassword, ...s }) => ({ ...s, userName })); - } - } - - const onRememberChange = (checked) => { - form.change('remember', checked); - - if (!checked && values.autoConnect) { - onAutoConnectChange(false); - } - - togglePasswordLabel(useStoredPassword(checked, values.password)); - } - - const onAutoConnectChange = (checked) => { - setAutoConnect(checked); - - form.change('autoConnect', checked); - - if (checked && !values.remember) { - form.change('remember', true); - } - } - - return ( - -
-
- - {onUserNameChange} -
-
- setUseStoredPasswordLabel(false)} - onBlur={() => togglePasswordLabel(useStoredPassword(values.remember, values.password))} - name='password' - type='password' - component={InputField} - autoComplete='new-password' - /> -
-
- - {onRememberChange} - - -
-
- - {setHost} -
-
- - {onAutoConnectChange} -
-
- -
- ) - }} + {({ handleSubmit, form }) => ( + + )} ); }; -interface LoginFormProps { - onSubmit: any; - disableSubmitButton: boolean, - onResetPassword: any; -} - export default LoginForm; diff --git a/webclient/src/hooks/__mocks__/useKnownHosts.ts b/webclient/src/hooks/__mocks__/useKnownHosts.ts new file mode 100644 index 000000000..341144939 --- /dev/null +++ b/webclient/src/hooks/__mocks__/useKnownHosts.ts @@ -0,0 +1,40 @@ +import type { HostDTO } from '@app/services'; +import { LoadingState } from '../useSharedStore'; +import type { KnownHostsHook, KnownHostsValue } from '../useKnownHosts'; + +export const makeHost = (overrides: Partial = {}): HostDTO => + ({ + id: 1, + name: 'Test Host', + host: 'test.example', + port: '4747', + editable: false, + lastSelected: true, + userName: undefined, + hashedPassword: undefined, + remember: false, + save: vi.fn(), + ...overrides, + }) as unknown as HostDTO; + +export const makeKnownHostsValue = (overrides: Partial = {}): KnownHostsValue => { + const host = makeHost(); + return { hosts: [host], selectedHost: host, ...overrides }; +}; + +export const makeKnownHostsHook = (overrides: Partial = {}): KnownHostsHook => + ({ + status: LoadingState.READY, + value: makeKnownHostsValue(), + select: vi.fn().mockResolvedValue(undefined), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn().mockResolvedValue(undefined), + ...overrides, + }) as KnownHostsHook; + +export const useKnownHosts = vi.fn<() => KnownHostsHook>(() => makeKnownHostsHook()); + +export const getKnownHosts = vi.fn<() => Promise>(() => + Promise.resolve(makeKnownHostsValue()) +); diff --git a/webclient/src/hooks/__mocks__/useSettings.ts b/webclient/src/hooks/__mocks__/useSettings.ts new file mode 100644 index 000000000..27d8a6c3f --- /dev/null +++ b/webclient/src/hooks/__mocks__/useSettings.ts @@ -0,0 +1,20 @@ +import type { SettingDTO } from '@app/services'; +import { LoadingState } from '../useSharedStore'; +import type { SettingsHook } from '../useSettings'; + +export const makeSettings = (overrides: Partial = {}): SettingDTO => + ({ user: '*app', autoConnect: false, save: vi.fn(), ...overrides }) as SettingDTO; + +export const makeSettingsHook = (overrides: Partial = {}): SettingsHook => + ({ + status: LoadingState.READY, + value: makeSettings(), + update: vi.fn().mockResolvedValue(undefined), + ...overrides, + }) as SettingsHook; + +export const useSettings = vi.fn<() => SettingsHook>(() => makeSettingsHook()); + +export const getSettings = vi.fn<() => Promise>(() => + Promise.resolve(makeSettings()) +); diff --git a/webclient/src/hooks/index.ts b/webclient/src/hooks/index.ts index 7c8e7c3ef..679bca669 100644 --- a/webclient/src/hooks/index.ts +++ b/webclient/src/hooks/index.ts @@ -1,5 +1,8 @@ -export * from './useAutoConnect'; +export * from './useAutoLogin'; export * from './useFireOnce'; +export * from './useKnownHosts'; export * from './useLocaleSort'; export * from './useReduxEffect'; +export * from './useSettings'; +export * from './useSharedStore'; export * from './useWebClient'; diff --git a/webclient/src/hooks/useAutoConnect.spec.ts b/webclient/src/hooks/useAutoConnect.spec.ts deleted file mode 100644 index d6b09d0e8..000000000 --- a/webclient/src/hooks/useAutoConnect.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { renderHook, act, waitFor } from '@testing-library/react'; -import { useAutoConnect } from './useAutoConnect'; - -const mockSave = vi.fn(); - -let storedSetting: any = null; - -vi.mock('@app/services', () => ({ - SettingDTO: class MockSettingDTO { - user: string; - autoConnect = false; - constructor(user: string) { - this.user = user; - } - save = mockSave; - static get = vi.fn(() => Promise.resolve(storedSetting)); - }, -})); - -vi.mock('@app/types', () => ({ - App: { APP_USER: '*app' }, -})); - -describe('useAutoConnect', () => { - beforeEach(() => { - storedSetting = null; - mockSave.mockClear(); - }); - - test('returns undefined initially, then resolves to stored autoConnect value', async () => { - storedSetting = { user: '*app', autoConnect: true, save: mockSave }; - - const { result } = renderHook(() => useAutoConnect()); - - expect(result.current[0]).toBeUndefined(); - - await waitFor(() => { - expect(result.current[0]).toBe(true); - }); - }); - - test('creates and saves a new SettingDTO when none exists', async () => { - storedSetting = null; - - const { result } = renderHook(() => useAutoConnect()); - - await waitFor(() => { - expect(result.current[0]).toBe(false); - }); - - // save() called once for the newly created setting - expect(mockSave).toHaveBeenCalledTimes(1); - }); - - test('persists when setAutoConnect is called', async () => { - storedSetting = { user: '*app', autoConnect: false, save: mockSave }; - - const { result } = renderHook(() => useAutoConnect()); - - await waitFor(() => { - expect(result.current[0]).toBe(false); - }); - - mockSave.mockClear(); - - act(() => { - result.current[1](true); - }); - - await waitFor(() => { - expect(result.current[0]).toBe(true); - }); - - expect(mockSave).toHaveBeenCalled(); - }); - - test('does not save redundantly on initial mount when setting exists', async () => { - storedSetting = { user: '*app', autoConnect: true, save: mockSave }; - - renderHook(() => useAutoConnect()); - - await waitFor(() => { - expect(mockSave).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/webclient/src/hooks/useAutoConnect.ts b/webclient/src/hooks/useAutoConnect.ts deleted file mode 100644 index a24a0b80f..000000000 --- a/webclient/src/hooks/useAutoConnect.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; - -import { SettingDTO } from '@app/services'; -import { App } from '@app/types'; - -export function useAutoConnect(): [boolean | undefined, Dispatch>] { - const [setting, setSetting] = useState(undefined); - const [autoConnect, setAutoConnect] = useState(undefined); - const prevAutoConnectRef = useRef(undefined); - - useEffect(() => { - SettingDTO.get(App.APP_USER).then((loaded: SettingDTO) => { - if (!loaded) { - loaded = new SettingDTO(App.APP_USER); - loaded.save(); - } - - setSetting(loaded); - setAutoConnect(loaded.autoConnect); - prevAutoConnectRef.current = loaded.autoConnect; - }); - }, []); - - useEffect(() => { - if (setting && autoConnect !== prevAutoConnectRef.current) { - prevAutoConnectRef.current = autoConnect; - setting.autoConnect = autoConnect; - setting.save(); - } - }, [autoConnect]); - - return [autoConnect, setAutoConnect]; -} diff --git a/webclient/src/hooks/useAutoLogin.spec.tsx b/webclient/src/hooks/useAutoLogin.spec.tsx new file mode 100644 index 000000000..064f683d3 --- /dev/null +++ b/webclient/src/hooks/useAutoLogin.spec.tsx @@ -0,0 +1,169 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +vi.mock('./useSettings'); +vi.mock('./useKnownHosts'); + +type AnyRecord = Record; + +let useAutoLoginModule: typeof import('./useAutoLogin'); +let getSettingsMock: any; +let getKnownHostsMock: any; +let makeSettings: (o?: AnyRecord) => AnyRecord; +let makeHost: (o?: AnyRecord) => AnyRecord; + +beforeEach(async () => { + // Fresh module graph per test so the module-level hasFiredThisSession flag resets. + vi.resetModules(); + useAutoLoginModule = await import('./useAutoLogin'); + const settingsMockModule = await import('./__mocks__/useSettings'); + const hostsMockModule = await import('./__mocks__/useKnownHosts'); + getSettingsMock = settingsMockModule.getSettings; + getKnownHostsMock = hostsMockModule.getKnownHosts; + makeSettings = settingsMockModule.makeSettings as any; + makeHost = hostsMockModule.makeHost as any; +}); + +interface ConfigureOptions { + autoConnect?: boolean; + remember?: boolean; + hashedPassword?: string; + userName?: string; +} + +const configure = ({ + autoConnect = false, + remember = false, + hashedPassword = undefined, + userName = 'joe', +}: ConfigureOptions) => { + const settings = makeSettings({ autoConnect }); + const host = makeHost({ remember, hashedPassword, userName, lastSelected: true }); + + getSettingsMock.mockResolvedValue(settings); + getKnownHostsMock.mockResolvedValue({ hosts: [host], selectedHost: host }); +}; + +describe('useAutoLogin', () => { + test('fires onLogin when all conditions are met', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: true, remember: true, hashedPassword: 'hp', userName: 'joe' }); + + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await waitFor(() => expect(onLogin).toHaveBeenCalledTimes(1)); + expect(onLogin.mock.calls[0][0]).toMatchObject({ + userName: 'joe', + remember: true, + password: '', + }); + }); + + test('does not fire when settings.autoConnect is false', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); + + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + // Let the pending promise flush. + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('does not fire when host lacks remember flag', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: true, remember: false, hashedPassword: 'hp' }); + + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('does not fire when host lacks hashedPassword', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: true, remember: true, hashedPassword: undefined }); + + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('does not fire when a connection attempt is already in flight', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); + + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, true)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('fires at most once per session, even across unmount + remount', async () => { + const onLogin = vi.fn(); + configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); + + const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + await waitFor(() => expect(onLogin).toHaveBeenCalledTimes(1)); + + unmount(); + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).toHaveBeenCalledTimes(1); + }); + + test('manual login then logout does NOT auto-connect on return to /login', async () => { + // Regression: the flag tracks whether the startup check RAN, not whether + // it FIRED. Without that distinction, a first-session manual login (where + // the hook saw conditions unmet) would leave the flag unset, and the + // next mount (after logout) would find conditions met and auto-connect. + const onLogin = vi.fn(); + + // First mount: autoConnect=false, so the check runs but doesn't fire. + configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); + const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + + // User logs in manually and later hits logout; Login re-mounts with + // autoConnect now flipped on (they ticked the box during the session). + unmount(); + configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('ticking the auto-connect checkbox after mount does NOT trigger a login', async () => { + // This is the specific regression: editing the persisted preference is a + // settings write, not a "log in now" signal. Because useAutoLogin reads + // via whenReady (one-shot) instead of subscribing, a subsequent settings + // change cannot re-run the orchestrator. + const onLogin = vi.fn(); + configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); + + const { rerender } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + + // Swap to a "settings.autoConnect=true" world and rerender. Since + // getSettings is a one-shot that already resolved with the old value, + // changing its mockResolvedValue doesn't retroactively matter. + configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); + rerender(); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/hooks/useAutoLogin.ts b/webclient/src/hooks/useAutoLogin.ts new file mode 100644 index 000000000..bf4944dca --- /dev/null +++ b/webclient/src/hooks/useAutoLogin.ts @@ -0,0 +1,76 @@ +import { useEffect } from 'react'; + +import type { HostDTO } from '@app/services'; + +import { getKnownHosts } from './useKnownHosts'; +import { getSettings } from './useSettings'; + +export interface LoginFormValues { + userName: string; + password?: string; + selectedHost: HostDTO; + remember: boolean; + autoConnect?: boolean; +} + +// Auto-login is a *startup* concern — the persisted preference is consulted +// once per JS session, after both stores have loaded. A logout within the +// same session is an explicit user action; returning to /login should not +// re-auto-connect (matches Cockatrice desktop behaviour). The flag is +// module-scope so it persists across Login remounts and is naturally reset +// on page refresh, which is the one time we do want another try. +// +// The flag tracks whether the *check* has run, not whether it *fired* — a +// manual first login followed by a logout must not re-trigger auto-login +// either, so the outcome of the check is irrelevant; only that it happened. +// +// Exported as a mutable object (rather than a bare `let`) so integration +// tests can reset `startupCheckRan = false` between scenarios without +// resorting to `vi.resetModules`, which is prohibitively slow in the full +// suite. Production code only writes the flag from inside the effect. +export const autoLoginSession = { startupCheckRan: false }; + +// Deliberately does NOT subscribe to the settings / known-hosts stores — +// user actions that change those stores (ticking the auto-connect checkbox, +// picking a different host) are preference edits, not "log in now" signals. +export function useAutoLogin( + onLogin: (values: LoginFormValues) => void, + connectionAttemptMade: boolean, +): void { + useEffect(() => { + if (autoLoginSession.startupCheckRan) { + return; + } + if (connectionAttemptMade) { + return; + } + + let cancelled = false; + + Promise.all([getSettings(), getKnownHosts()]).then(([settings, hosts]) => { + if (cancelled || autoLoginSession.startupCheckRan) { + return; + } + autoLoginSession.startupCheckRan = true; + + if (!settings.autoConnect) { + return; + } + const { selectedHost } = hosts; + if (!selectedHost?.remember || !selectedHost?.hashedPassword) { + return; + } + + onLogin({ + selectedHost, + userName: selectedHost.userName ?? '', + remember: true, + password: '', + }); + }); + + return () => { + cancelled = true; + }; + }, [connectionAttemptMade, onLogin]); +} diff --git a/webclient/src/hooks/useKnownHosts.spec.ts b/webclient/src/hooks/useKnownHosts.spec.ts new file mode 100644 index 000000000..40de6a8af --- /dev/null +++ b/webclient/src/hooks/useKnownHosts.spec.ts @@ -0,0 +1,211 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; + +type StoredHost = { + id?: number; + name: string; + host: string; + port: string; + editable: boolean; + lastSelected?: boolean; + userName?: string; + hashedPassword?: string; + remember?: boolean; + save?: ReturnType; +}; + +let stored: StoredHost[] = []; +let nextId = 1; + +async function upsertStoredHost(this: StoredHost) { + const idx = stored.findIndex((h) => h.id === this.id); + if (idx >= 0) { + stored[idx] = this; + } else { + this.id = this.id ?? nextId++; + stored.push(this); + } +} + +const mockSave = vi.fn<(self: StoredHost) => Promise>(upsertStoredHost); + +vi.mock('@app/services', () => ({ + HostDTO: class MockHostDTO { + id?: number; + name!: string; + host!: string; + port!: string; + editable!: boolean; + lastSelected?: boolean; + userName?: string; + hashedPassword?: string; + remember?: boolean; + + save = function save(this: StoredHost) { + return mockSave.call(this); + }; + + static getAll = vi.fn(async () => { + return stored.map((h) => { + const inst = new MockHostDTO() as unknown as StoredHost; + Object.assign(inst, h); + (inst as unknown as MockHostDTO).save = function save() { + return mockSave.call(this as unknown as StoredHost); + }; + return inst; + }); + }); + + static get = vi.fn(async (id: number) => { + const match = stored.find((h) => h.id === id); + if (!match) { + return undefined; + } + const inst = new MockHostDTO() as unknown as StoredHost; + Object.assign(inst, match); + (inst as unknown as MockHostDTO).save = function save() { + return mockSave.call(this as unknown as StoredHost); + }; + return inst; + }); + + static add = vi.fn(async (host: StoredHost) => { + const id = nextId++; + stored.push({ ...host, id }); + return id; + }); + + static bulkAdd = vi.fn(async (hosts: StoredHost[]) => { + for (const h of hosts) { + stored.push({ ...h, id: nextId++ }); + } + }); + + static delete = vi.fn(async (id: number | string) => { + const numericId = typeof id === 'string' ? Number(id) : id; + stored = stored.filter((h) => h.id !== numericId); + }); + }, + DefaultHosts: [ + { name: 'A', host: 'a.x', port: '1', editable: false }, + { name: 'B', host: 'b.x', port: '2', editable: false }, + ], +})); + +vi.mock('@app/types', () => ({ App: {} })); + +let useKnownHostsModule: typeof import('./useKnownHosts'); +let LoadingState: typeof import('./useSharedStore').LoadingState; + +beforeEach(async () => { + vi.resetModules(); + stored = []; + nextId = 1; + mockSave.mockClear(); + useKnownHostsModule = await import('./useKnownHosts'); + ({ LoadingState } = await import('./useSharedStore')); +}); + +describe('useKnownHosts', () => { + test('seeds DefaultHosts when the DB is empty and pins hosts[0] as lastSelected', async () => { + const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); + + await waitFor(() => { + expect(result.current.status).toBe(LoadingState.READY); + }); + + if (result.current.status !== LoadingState.READY) { + throw new Error('not ready'); + } + expect(result.current.value.hosts).toHaveLength(2); + expect(result.current.value.selectedHost.name).toBe('A'); + expect(result.current.value.selectedHost.lastSelected).toBe(true); + }); + + test('select(id) flips lastSelected atomically — exactly one row true', async () => { + stored = [ + { id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true }, + { id: 2, name: 'B', host: 'b', port: '2', editable: false, lastSelected: false }, + ]; + nextId = 3; + + const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); + + await waitFor(() => { + expect(result.current.status).toBe(LoadingState.READY); + }); + + await act(async () => { + await result.current.select(2); + }); + + if (result.current.status !== LoadingState.READY) { + throw new Error('not ready'); + } + expect(result.current.value.selectedHost.id).toBe(2); + const lastSelectedCount = result.current.value.hosts.filter((h) => h.lastSelected).length; + expect(lastSelectedCount).toBe(1); + }); + + test('add() persists to Dexie and mirrors the new host into in-memory state', async () => { + stored = [{ id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true }]; + nextId = 2; + + const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); + await waitFor(() => expect(result.current.status).toBe(LoadingState.READY)); + + await act(async () => { + await result.current.add({ name: 'C', host: 'c', port: '3', editable: true }); + }); + + if (result.current.status !== LoadingState.READY) { + throw new Error('not ready'); + } + expect(result.current.value.hosts).toHaveLength(2); + expect(result.current.value.hosts.some((h) => h.name === 'C')).toBe(true); + }); + + test('update() patches the host and replaces the snapshot reference', async () => { + stored = [ + { id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true, remember: false }, + ]; + nextId = 2; + + const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); + await waitFor(() => expect(result.current.status).toBe(LoadingState.READY)); + + const before = result.current; + + await act(async () => { + await result.current.update(1, { remember: true, userName: 'joe' }); + }); + + expect(result.current).not.toBe(before); + if (result.current.status !== LoadingState.READY) { + throw new Error('not ready'); + } + expect(result.current.value.hosts[0].remember).toBe(true); + expect(result.current.value.hosts[0].userName).toBe('joe'); + }); + + test('remove() deletes and picks a new selectedHost when the removed row was selected', async () => { + stored = [ + { id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true }, + { id: 2, name: 'B', host: 'b', port: '2', editable: false, lastSelected: false }, + ]; + nextId = 3; + + const { result } = renderHook(() => useKnownHostsModule.useKnownHosts()); + await waitFor(() => expect(result.current.status).toBe(LoadingState.READY)); + + await act(async () => { + await result.current.remove(1); + }); + + if (result.current.status !== LoadingState.READY) { + throw new Error('not ready'); + } + expect(result.current.value.hosts).toHaveLength(1); + expect(result.current.value.selectedHost.id).toBe(2); + expect(result.current.value.selectedHost.lastSelected).toBe(true); + }); +}); diff --git a/webclient/src/hooks/useKnownHosts.ts b/webclient/src/hooks/useKnownHosts.ts new file mode 100644 index 000000000..c7e95c89c --- /dev/null +++ b/webclient/src/hooks/useKnownHosts.ts @@ -0,0 +1,132 @@ +import { DefaultHosts, HostDTO } from '@app/services'; +import { App } from '@app/types'; + +import { createSharedStore, Loadable, useSharedStore } from './useSharedStore'; + +// Shared-store scope justification: multiple components on the login screen +// read the same host list and selected host simultaneously (KnownHosts +// dropdown, LoginForm's host-sync effect, useAutoLogin, and the Login +// container's post-login write). Collapsing to useState inside one component +// would duplicate Dexie reads and race on lastSelected updates — exactly the +// bug we set out to fix. +export interface KnownHostsValue { + hosts: HostDTO[]; + selectedHost: HostDTO; +} + +const loadAll = async (): Promise => { + let hosts = await HostDTO.getAll(); + if (!hosts?.length) { + await HostDTO.bulkAdd(DefaultHosts); + hosts = await HostDTO.getAll(); + } + return hosts; +}; + +const normalize = async (hosts: HostDTO[]): Promise => { + const existing = hosts.find((h) => h.lastSelected); + if (existing) { + return { hosts, selectedHost: existing }; + } + + // No row marked lastSelected yet (first-ever load after seeding, or legacy + // data). Pin hosts[0] and persist so subsequent boots are deterministic. + const selected = hosts[0]; + selected.lastSelected = true; + await selected.save(); + return { hosts, selectedHost: selected }; +}; + +// Exported for integration-test reset; see settingsStore for the rationale. +export const knownHostsStore = createSharedStore(async () => { + const hosts = await loadAll(); + return normalize(hosts); +}); +const store = knownHostsStore; + +export type KnownHostsHook = Loadable & { + select: (id: number) => Promise; + add: (host: App.Host) => Promise; + update: (id: number, patch: Partial) => Promise; + remove: (id: number) => Promise; +}; + +// Guard for mutators. Mutators run outside React render, so we can't gate +// them through the hook's status; peek + throw is the fail-fast alternative. +const requireValue = (method: string): KnownHostsValue => { + const current = store.peek(); + if (!current) { + throw new Error(`useKnownHosts.${method} called before hosts loaded`); + } + return current; +}; + +const select = async (id: number): Promise => { + const { hosts } = requireValue('select'); + const target = hosts.find((h) => h.id === id); + if (!target) { + throw new Error(`useKnownHosts.select: unknown host id ${id}`); + } + + const writes: Promise[] = []; + for (const h of hosts) { + if (h === target) { + if (!h.lastSelected) { + h.lastSelected = true; + writes.push(h.save()); + } + } else if (h.lastSelected) { + h.lastSelected = false; + writes.push(h.save()); + } + } + await Promise.all(writes); + + store.setValue({ hosts: [...hosts], selectedHost: target }); +}; + +const add = async (host: App.Host): Promise => { + const { hosts, selectedHost } = requireValue('add'); + const created = await HostDTO.get((await HostDTO.add(host)) as number); + store.setValue({ hosts: [...hosts, created], selectedHost }); + return created; +}; + +const update = async (id: number, patch: Partial): Promise => { + const { hosts, selectedHost } = requireValue('update'); + const existing = hosts.find((h) => h.id === id); + if (!existing) { + throw new Error(`useKnownHosts.update: unknown host id ${id}`); + } + Object.assign(existing, patch); + await existing.save(); + store.setValue({ + hosts: [...hosts], + selectedHost: selectedHost.id === id ? existing : selectedHost, + }); + return existing; +}; + +const remove = async (id: number): Promise => { + const { hosts, selectedHost } = requireValue('remove'); + await HostDTO.delete(id as unknown as string); + const next = hosts.filter((h) => h.id !== id); + let nextSelected = selectedHost; + if (selectedHost.id === id) { + nextSelected = next[0]; + if (nextSelected) { + nextSelected.lastSelected = true; + await nextSelected.save(); + } + } + store.setValue({ hosts: next, selectedHost: nextSelected }); +}; + +export function useKnownHosts(): KnownHostsHook { + const state = useSharedStore(store); + return { ...state, select, add, update, remove }; +} + +// Non-reactive one-shot accessor, mirroring getSettings. See the comment on +// that export in useSettings.ts for the rationale. +export const getKnownHosts = (): Promise => store.whenReady(); diff --git a/webclient/src/hooks/useSettings.spec.ts b/webclient/src/hooks/useSettings.spec.ts new file mode 100644 index 000000000..6fe9361f8 --- /dev/null +++ b/webclient/src/hooks/useSettings.spec.ts @@ -0,0 +1,98 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; + +const mockSave = vi.fn(); +let storedSetting: any = null; + +vi.mock('@app/services', () => ({ + SettingDTO: class MockSettingDTO { + user: string; + autoConnect = false; + constructor(user: string) { + this.user = user; + } + save = mockSave; + static get = vi.fn(() => Promise.resolve(storedSetting)); + }, +})); + +vi.mock('@app/types', () => ({ + App: { APP_USER: '*app' }, +})); + +// Each spec resets module state so the shared store starts fresh. +let useSettingsModule: typeof import('./useSettings'); +let LoadingState: typeof import('./useSharedStore').LoadingState; + +beforeEach(async () => { + vi.resetModules(); + storedSetting = null; + mockSave.mockClear(); + useSettingsModule = await import('./useSettings'); + ({ LoadingState } = await import('./useSharedStore')); +}); + +describe('useSettings', () => { + test('starts in loading state, then resolves to the stored setting', async () => { + storedSetting = { user: '*app', autoConnect: true, save: mockSave }; + + const { result } = renderHook(() => useSettingsModule.useSettings()); + + expect(result.current.status).toBe(LoadingState.LOADING); + + await waitFor(() => { + expect(result.current.status).toBe(LoadingState.READY); + }); + + if (result.current.status === LoadingState.READY) { + expect(result.current.value.autoConnect).toBe(true); + } + }); + + test('creates and saves a new SettingDTO when none exists', async () => { + storedSetting = null; + + const { result } = renderHook(() => useSettingsModule.useSettings()); + + await waitFor(() => { + expect(result.current.status).toBe(LoadingState.READY); + }); + + if (result.current.status === LoadingState.READY) { + expect(result.current.value.autoConnect).toBe(false); + } + expect(mockSave).toHaveBeenCalledTimes(1); + }); + + test('update() persists the patch and re-renders with a new snapshot', async () => { + storedSetting = { user: '*app', autoConnect: false, save: mockSave }; + + const { result } = renderHook(() => useSettingsModule.useSettings()); + + await waitFor(() => { + expect(result.current.status).toBe(LoadingState.READY); + }); + + mockSave.mockClear(); + const before = result.current; + + await act(async () => { + await result.current.update({ autoConnect: true }); + }); + + expect(result.current).not.toBe(before); + if (result.current.status === LoadingState.READY) { + expect(result.current.value.autoConnect).toBe(true); + } + expect(mockSave).toHaveBeenCalledTimes(1); + }); + + test('does not re-save on the initial load when setting already exists', async () => { + storedSetting = { user: '*app', autoConnect: true, save: mockSave }; + + renderHook(() => useSettingsModule.useSettings()); + + await waitFor(() => { + expect(mockSave).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/webclient/src/hooks/useSettings.ts b/webclient/src/hooks/useSettings.ts new file mode 100644 index 000000000..9636634cf --- /dev/null +++ b/webclient/src/hooks/useSettings.ts @@ -0,0 +1,52 @@ +import { SettingDTO } from '@app/services'; +import { App } from '@app/types'; + +import { createSharedStore, Loadable, useSharedStore } from './useSharedStore'; + +// First-time bootstrap: SettingDTO.get returns undefined when no row exists +// for the app user yet (fresh install, or a user who has never hit the +// settings code path before). We materialize a default DTO and persist it so +// subsequent loads always see a non-null row. +// +// Exported as `settingsStore` so integration tests can call +// `settingsStore.reset()` between scenarios — the module cache would +// otherwise serve stale data across per-test Dexie resets. Production code +// goes through `useSettings()` / `getSettings()` and doesn't touch this. +export const settingsStore = createSharedStore(async () => { + let loaded = await SettingDTO.get(App.APP_USER); + if (!loaded) { + loaded = new SettingDTO(App.APP_USER); + await loaded.save(); + } + return loaded; +}); +const store = settingsStore; + +export type SettingsHook = Loadable & { + update: (patch: Partial) => Promise; +}; + +export function useSettings(): SettingsHook { + const state = useSharedStore(store); + + const update = async (patch: Partial) => { + // Fail-fast if a caller tries to write before the initial load resolves. + // Shouldn't happen in normal flow (the checkbox is gated on the hook's + // ready status), so surface the bug loudly instead of silently no-oping. + const current = store.peek(); + if (!current) { + throw new Error('useSettings.update called before settings loaded'); + } + Object.assign(current, patch); + await current.save(); + store.setValue(current); + }; + + return { ...state, update }; +} + +// Non-reactive one-shot accessor. Use this from code that wants the loaded +// value exactly once and does NOT want to re-run when the user subsequently +// edits their settings — e.g. the auto-login orchestrator, which consults +// the persisted preference at startup only. +export const getSettings = (): Promise => store.whenReady(); diff --git a/webclient/src/hooks/useSharedStore.spec.ts b/webclient/src/hooks/useSharedStore.spec.ts new file mode 100644 index 000000000..a104e16d8 --- /dev/null +++ b/webclient/src/hooks/useSharedStore.spec.ts @@ -0,0 +1,102 @@ +import { createSharedStore, LoadingState } from './useSharedStore'; + +describe('createSharedStore', () => { + test('starts in LOADING state before any subscriber connects', () => { + const store = createSharedStore(async () => 'value'); + expect(store.getSnapshot()).toEqual({ status: LoadingState.LOADING }); + expect(store.peek()).toBeUndefined(); + }); + + test('triggers load on first subscribe and resolves to READY', async () => { + let loadCalls = 0; + const store = createSharedStore(async () => { + loadCalls++; + return 42; + }); + + const cb = vi.fn(); + store.subscribe(cb); + + await vi.waitFor(() => { + expect(store.getSnapshot()).toEqual({ status: LoadingState.READY, value: 42 }); + }); + + expect(loadCalls).toBe(1); + expect(cb).toHaveBeenCalled(); + expect(store.peek()).toBe(42); + }); + + test('multiple subscribers share a single load', async () => { + let loadCalls = 0; + const store = createSharedStore(async () => { + loadCalls++; + return 'shared'; + }); + + const cb1 = vi.fn(); + const cb2 = vi.fn(); + store.subscribe(cb1); + store.subscribe(cb2); + + await vi.waitFor(() => { + expect(store.getSnapshot()).toEqual({ status: LoadingState.READY, value: 'shared' }); + }); + + expect(loadCalls).toBe(1); + expect(cb1).toHaveBeenCalled(); + expect(cb2).toHaveBeenCalled(); + }); + + test('setValue notifies subscribers with a new snapshot reference', async () => { + const store = createSharedStore(async () => ({ n: 1 })); + const cb = vi.fn(); + store.subscribe(cb); + + await vi.waitFor(() => expect(store.getSnapshot().status).toBe(LoadingState.READY)); + + const before = store.getSnapshot(); + cb.mockClear(); + + store.setValue({ n: 2 }); + + const after = store.getSnapshot(); + expect(after).not.toBe(before); + expect(after).toEqual({ status: LoadingState.READY, value: { n: 2 } }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + test('transitions to ERROR when loader rejects and notifies subscribers', async () => { + const store = createSharedStore(async () => { + throw new Error('boom'); + }); + + const cb = vi.fn(); + store.subscribe(cb); + + await vi.waitFor(() => { + expect(store.getSnapshot().status).toBe(LoadingState.ERROR); + }); + + const snapshot = store.getSnapshot(); + expect(snapshot.status).toBe(LoadingState.ERROR); + if (snapshot.status === LoadingState.ERROR) { + expect(snapshot.error.message).toBe('boom'); + } + expect(cb).toHaveBeenCalled(); + expect(store.peek()).toBeUndefined(); + }); + + test('unsubscribe removes the callback', async () => { + const store = createSharedStore(async () => 'x'); + const cb = vi.fn(); + const unsubscribe = store.subscribe(cb); + + await vi.waitFor(() => expect(store.getSnapshot().status).toBe(LoadingState.READY)); + cb.mockClear(); + + unsubscribe(); + store.setValue('y'); + + expect(cb).not.toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/hooks/useSharedStore.ts b/webclient/src/hooks/useSharedStore.ts new file mode 100644 index 000000000..66505a168 --- /dev/null +++ b/webclient/src/hooks/useSharedStore.ts @@ -0,0 +1,127 @@ +import { useSyncExternalStore } from 'react'; + +export enum LoadingState { + LOADING = 'loading', + READY = 'ready', + ERROR = 'error', +} + +export interface Loadable { + status: LoadingState; + value?: T; + error?: Error; +} + +export interface SharedStore { + // Reactive surface: subscribe + snapshot back useSyncExternalStore so + // consuming components re-render on every store update. + subscribe: (cb: () => void) => () => void; + getSnapshot: () => Loadable; + + // One-shot surface: whenReady resolves with the initial loaded value and + // never fires again. Callers that only need "read once after init" (e.g. + // the auto-login orchestrator) use this to avoid subscribing to updates + // they don't care about — which would otherwise turn a user preference + // toggle into a re-evaluation of startup logic. + whenReady: () => Promise; + + // Mutator-side helpers, not for consumption inside render. + setValue: (value: T) => void; + peek: () => T | undefined; + + // Clear cached state and the resolved readyPromise; the next subscribe / + // whenReady call triggers a fresh load. In production nobody calls this; + // integration tests use it to discard per-test Dexie state without + // paying the cost of vi.resetModules across the whole dep graph. + reset: () => void; +} + +export function createSharedStore(load: () => Promise): SharedStore { + let state: Loadable = { status: LoadingState.LOADING }; + const subscribers = new Set<() => void>(); + let loadStarted = false; + + // whenReady is lazy: we only attach a promise once someone asks for one. + // This avoids Node's unhandled-rejection bookkeeping for stores whose + // loader fails but never had a whenReady caller. + let readyPromise: Promise | null = null; + + const notify = () => { + for (const cb of subscribers) { + cb(); + } + }; + + const ensureLoaded = () => { + if (loadStarted) { + return; + } + loadStarted = true; + load().then( + (value) => { + state = { status: LoadingState.READY, value }; + notify(); + }, + (error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + state = { status: LoadingState.ERROR, error: err }; + notify(); + } + ); + }; + + const subscribe = (cb: () => void) => { + subscribers.add(cb); + ensureLoaded(); + return () => { + subscribers.delete(cb); + }; + }; + + return { + subscribe, + getSnapshot: () => state, + whenReady: () => { + ensureLoaded(); + if (!readyPromise) { + readyPromise = new Promise((resolve, reject) => { + const settle = (): boolean => { + if (state.status === LoadingState.READY) { + resolve(state.value as T); + return true; + } + if (state.status === LoadingState.ERROR && state.error) { + reject(state.error); + return true; + } + return false; + }; + if (settle()) { + return; + } + const unsub = subscribe(() => { + if (settle()) { + unsub(); + } + }); + }); + } + return readyPromise; + }, + setValue: (value) => { + state = { status: LoadingState.READY, value }; + notify(); + }, + peek: () => (state.status === LoadingState.READY ? (state.value as T) : undefined), + reset: () => { + state = { status: LoadingState.LOADING }; + loadStarted = false; + readyPromise = null; + notify(); + }, + }; +} + +export function useSharedStore(store: SharedStore): Loadable { + return useSyncExternalStore(store.subscribe, store.getSnapshot); +} diff --git a/webclient/src/hooks/useWebClient.tsx b/webclient/src/hooks/useWebClient.tsx index 469f03dc9..37570345c 100644 --- a/webclient/src/hooks/useWebClient.tsx +++ b/webclient/src/hooks/useWebClient.tsx @@ -2,7 +2,10 @@ import { createContext, useContext, useState, ReactNode } from 'react'; import { WebClient } from '@app/websocket'; import { createWebClientRequest, createWebClientResponse } from '@app/api'; -const WebClientContext = createContext(null); +// Exported so integration tests can inject the WebClient singleton built +// by their shared setup without going through the production provider +// (which would attempt to `new WebClient(...)` a second time and throw). +export const WebClientContext = createContext(null); export function WebClientProvider({ children }: { children: ReactNode }) { const [client] = useState(() => new WebClient(createWebClientRequest(), createWebClientResponse())); diff --git a/webclient/vite.config.ts b/webclient/vite.config.ts index 1c1157f0a..225e64adc 100644 --- a/webclient/vite.config.ts +++ b/webclient/vite.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ }, server: { open: true, + watch: { + ignored: ['build', 'coverage', 'integration'] + } }, test: { globals: true, diff --git a/webclient/vitest.integration.config.ts b/webclient/vitest.integration.config.ts index 0b88f927a..4308e2ccf 100644 --- a/webclient/vitest.integration.config.ts +++ b/webclient/vitest.integration.config.ts @@ -15,7 +15,11 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./integration/src/helpers/setup.ts'], - include: ['integration/src/**/*.spec.ts'], + include: ['integration/src/**/*.spec.{ts,tsx}'], + // App-suite tests render the full Login container against real Dexie + // (fake-indexeddb) + real WebClient. Under CI/disk load the default + // 5s timeout is tight; 10s leaves headroom without masking real hangs. + testTimeout: 10000, coverage: { provider: 'v8', reporter: ['text', 'html'],