refactor login flow and hooks, address autologin issues

This commit is contained in:
seavor 2026-04-18 10:14:31 -05:00
parent dcd6dc00f4
commit bd2382c94e
43 changed files with 2179 additions and 484 deletions

View file

@ -0,0 +1,33 @@
// Shared render helper for the app integration suite.
//
// Two non-obvious choices:
//
// 1. WebClientContext is provided directly (not via production
// <WebClientProvider>) 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(
<WebClientContext.Provider value={WebClient.instance}>
{ui}
</WebClientContext.Provider>,
{ store }
);
}
export { store };

View file

@ -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<void> => {
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(<Login />);
await waitFor(() => {
expect(attempted()).toBe(true);
});
});
it('does NOT attempt login when Dexie has no settings row', async () => {
renderAppScreen(<Login />);
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(<Login />);
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(<Login />);
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(<Login />);
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(<Login />);
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(<Login />);
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(<Login />);
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(<Login />);
await waitFor(() => {
expect(attempted()).toBe(true);
});
});
});

View file

@ -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';

View file

@ -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> = {}): 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);
});
});

View file

@ -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<void> {
await Promise.all([
dexieService.settings.clear(),
dexieService.hosts.clear(),
]);
}

View file

@ -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');
});
});

View file

@ -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', () => {

View file

@ -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, {

View file

@ -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 {

View file

@ -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', () => {

View file

@ -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(

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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<{

View file

@ -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', () => {

View file

@ -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, {