diff --git a/frontend/src/spa/session-context-store.ts b/frontend/src/spa/session-context-store.ts new file mode 100644 index 0000000..b92f5be --- /dev/null +++ b/frontend/src/spa/session-context-store.ts @@ -0,0 +1,123 @@ +export interface SessionContext { + sessionCode: string; + playerId: number; + token: string; +} + +export interface SessionContextInput { + sessionCode: string; + playerId: number; + token: string; +} + +export interface SessionContextStore { + get(): SessionContext | null; + set(input: SessionContextInput): SessionContext; + clear(): void; +} + +export interface StorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +const DEFAULT_STORAGE_KEY = 'wpp.session-context'; + +function normalizeSessionCode(value: string): string { + return value.trim().toUpperCase(); +} + +function normalizeToken(value: string): string { + return value.trim(); +} + +function toContext(input: SessionContextInput): SessionContext { + const sessionCode = normalizeSessionCode(input.sessionCode); + const token = normalizeToken(input.token); + + if (!sessionCode) { + throw new Error('sessionCode is required'); + } + if (!Number.isInteger(input.playerId) || input.playerId <= 0) { + throw new Error('playerId must be a positive integer'); + } + if (!token) { + throw new Error('token is required'); + } + + return { + sessionCode, + playerId: input.playerId, + token + }; +} + +function safeParse(raw: string): SessionContext | null { + try { + const data = JSON.parse(raw) as Partial; + if (typeof data.sessionCode !== 'string' || typeof data.playerId !== 'number' || typeof data.token !== 'string') { + return null; + } + return toContext({ + sessionCode: data.sessionCode, + playerId: data.playerId, + token: data.token + }); + } catch { + return null; + } +} + +export function createSessionContextStore(storage?: StorageLike, storageKey = DEFAULT_STORAGE_KEY): SessionContextStore { + let current: SessionContext | null = null; + + function getFromStorage(): SessionContext | null { + if (!storage) { + return null; + } + + const raw = storage.getItem(storageKey); + if (!raw) { + return null; + } + + const parsed = safeParse(raw); + if (!parsed) { + storage.removeItem(storageKey); + return null; + } + + return parsed; + } + + return { + get(): SessionContext | null { + if (current) { + return { ...current }; + } + + const fromStorage = getFromStorage(); + if (fromStorage) { + current = fromStorage; + return { ...current }; + } + + return null; + }, + set(input: SessionContextInput): SessionContext { + const normalized = toContext(input); + current = normalized; + if (storage) { + storage.setItem(storageKey, JSON.stringify(normalized)); + } + return { ...normalized }; + }, + clear(): void { + current = null; + if (storage) { + storage.removeItem(storageKey); + } + } + }; +} diff --git a/frontend/tests/session-context-store.test.ts b/frontend/tests/session-context-store.test.ts new file mode 100644 index 0000000..f27fe93 --- /dev/null +++ b/frontend/tests/session-context-store.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { createSessionContextStore, type StorageLike } from '../src/spa/session-context-store'; + +function makeMemoryStorage(seed?: Record): StorageLike { + const memory = new Map(Object.entries(seed ?? {})); + return { + getItem: (key: string) => memory.get(key) ?? null, + setItem: (key: string, value: string) => { + memory.set(key, value); + }, + removeItem: (key: string) => { + memory.delete(key); + } + }; +} + +describe('session context store', () => { + it('normalizes and persists sessionCode/playerId/token', () => { + const storage = makeMemoryStorage(); + const store = createSessionContextStore(storage, 'ctx'); + + const value = store.set({ sessionCode: ' abcd12 ', playerId: 12, token: ' token-1 ' }); + expect(value).toEqual({ sessionCode: 'ABCD12', playerId: 12, token: 'token-1' }); + + expect(store.get()).toEqual({ sessionCode: 'ABCD12', playerId: 12, token: 'token-1' }); + expect(storage.getItem('ctx')).toBe('{"sessionCode":"ABCD12","playerId":12,"token":"token-1"}'); + }); + + it('loads from storage and clears invalid payloads', () => { + const storage = makeMemoryStorage({ ctx: '{"sessionCode":"","playerId":0,"token":""}' }); + const store = createSessionContextStore(storage, 'ctx'); + + expect(store.get()).toBeNull(); + expect(storage.getItem('ctx')).toBeNull(); + }); + + it('supports clear()', () => { + const storage = makeMemoryStorage(); + const store = createSessionContextStore(storage, 'ctx'); + + store.set({ sessionCode: 'ABCD12', playerId: 3, token: 'token-3' }); + store.clear(); + + expect(store.get()).toBeNull(); + expect(storage.getItem('ctx')).toBeNull(); + }); + + it('rejects invalid context writes', () => { + const store = createSessionContextStore(); + expect(() => store.set({ sessionCode: '', playerId: 1, token: 'token-1' })).toThrow('sessionCode is required'); + expect(() => store.set({ sessionCode: 'ABCD12', playerId: 0, token: 'token-1' })).toThrow( + 'playerId must be a positive integer' + ); + expect(() => store.set({ sessionCode: 'ABCD12', playerId: 2, token: ' ' })).toThrow('token is required'); + }); +});