/** * Stage 3 validation — session races, stale responses, and error policy (client + Pinia). * Run: npm run test:session */ import assert from 'node:assert/strict' import test from 'node:test' import { createPinia, setActivePinia } from 'pinia' import { createAuthLoginBody, createMeFixture } from './fixtures/me.fixture' import { installFetchMock, resetAppTestState } from './helpers/fetchMock' declare global { // eslint-disable-next-line no-var var routeHandlers: Map import('./helpers/fetchMock').MockResponse | Promise> } async function setupPinia() { setActivePinia(createPinia()) const { resetUserStores } = await import('../src/api/sync') resetUserStores() } test.beforeEach(async () => { installFetchMock() await resetAppTestState() await setupPinia() }) test.after(() => { console.log('session-sync tests finished') }) test('delayed GET /me after logout does not mutate Pinia', async () => { const { api } = await import('../src/api/client') const { applyMeResponse } = await import('../src/api/sync') const { invalidateSession } = await import('../src/api/authSession') const { StaleSessionError } = await import('../src/api/errors') const { useUserStore } = await import('../src/stores/user') const meOld = createMeFixture({ xp: 5, coins: 50, name: 'Account A' }) const meNew = createMeFixture({ xp: 99, coins: 999, name: 'Account B' }) globalThis.routeHandlers.set('/me', () => ({ status: 200, body: meOld, delayMs: 50 })) const mePromise = api.getMe() invalidateSession() applyMeResponse(meNew) await assert.rejects(mePromise, StaleSessionError) assert.equal(useUserStore().xp, 99) assert.equal(useUserStore().coins, 999) }) test('delayed GET /me after account switch throws StaleSessionError', async () => { const { api } = await import('../src/api/client') const { applyMeResponse } = await import('../src/api/sync') const { invalidateSession } = await import('../src/api/authSession') const { StaleSessionError } = await import('../src/api/errors') const { useUserStore } = await import('../src/stores/user') const meA = createMeFixture({ xp: 1, name: 'A' }) const meB = createMeFixture({ xp: 2, name: 'B' }) globalThis.routeHandlers.set('/me', () => ({ status: 200, body: meA, delayMs: 30 })) const p = api.getMe() invalidateSession() applyMeResponse(meB) await assert.rejects(p, StaleSessionError) assert.equal(useUserStore().xp, 2) }) test('delayed 401 from prior session does not clear current session', async () => { const { api } = await import('../src/api/client') const { applyMeResponse } = await import('../src/api/sync') const { invalidateSession, setUnauthorizedHandler } = await import('../src/api/authSession') const { setAuthToken, getAuthToken } = await import('../src/api/token') const { useUserStore } = await import('../src/stores/user') let unauthorizedCalls = 0 setUnauthorizedHandler(() => { unauthorizedCalls += 1 }) globalThis.routeHandlers.set('/me', () => ({ status: 401, body: { error: 'expired' }, delayMs: 25 })) setAuthToken('token-a') const stale401 = api.getMe() invalidateSession() setAuthToken('token-b') applyMeResponse(createMeFixture({ xp: 77, name: 'B' })) await stale401.catch(() => undefined) assert.equal(unauthorizedCalls, 0) assert.equal(getAuthToken(), 'token-b') assert.equal(useUserStore().xp, 77) }) test('stale login response cannot replace newer login token and state', async () => { const { api } = await import('../src/api/client') const { invalidateSession } = await import('../src/api/authSession') const { setAuthToken, getAuthToken } = await import('../src/api/token') const { applyMeResponse } = await import('../src/api/sync') const { StaleSessionError } = await import('../src/api/errors') const { useUserStore } = await import('../src/stores/user') const meSlow = createMeFixture({ xp: 1, name: 'Slow' }) const meFast = createMeFixture({ xp: 50, name: 'Fast' }) globalThis.routeHandlers.set('/auth/login', (init) => { const body = JSON.parse(String(init?.body ?? '{}')) as { username?: string } if (body.username === 'slow') { return { status: 200, body: createAuthLoginBody('token-slow', meSlow), delayMs: 40 } } return { status: 200, body: createAuthLoginBody('token-fast', meFast) } }) invalidateSession() const slowLogin = api.login('slow', 'pass') invalidateSession() setAuthToken(null) const fast = await api.login('fast', 'pass') applyMeResponse(fast.me) setAuthToken(fast.token) await assert.rejects(slowLogin, StaleSessionError) assert.equal(getAuthToken(), 'token-fast') assert.equal(useUserStore().xp, 50) }) test('stale bootstrap /me cannot roll back XP after lesson complete (same session)', async () => { const { bootstrapFromServer, applyMeResponse, captureUserStateRevision } = await import( '../src/api/sync' ) const { useUserStore } = await import('../src/stores/user') const { useGamesStore } = await import('../src/stores/games') const meBootstrap = createMeFixture({ xp: 10, coins: 100, totalStars: 0 }) const meAfterLesson = createMeFixture({ xp: 55, coins: 130, totalStars: 3 }) globalThis.routeHandlers.set('/me', () => ({ status: 200, body: meBootstrap, delayMs: 35 })) const revBefore = captureUserStateRevision() const bootstrapPromise = bootstrapFromServer() applyMeResponse(meAfterLesson) await bootstrapPromise assert.ok(captureUserStateRevision() > revBefore) assert.equal(useUserStore().xp, 55) assert.equal(useUserStore().coins, 130) assert.equal(useGamesStore().totalStars, 3) }) test('out-of-order gold-land progress responses keep newest map state', async () => { const { goldLandApi } = await import('../src/modules/gold-land/services/goldLand.api') const { StaleSessionError } = await import('../src/api/errors') let call = 0 globalThis.routeHandlers.set('/gold-land/progress', () => { call += 1 if (call === 1) { return { status: 200, body: { levels: [{ id: 1, title: 'Old', status: 'available', stars: 0, maxStars: 3 }], }, delayMs: 40, } } return { status: 200, body: { levels: [{ id: 1, title: 'New', status: 'completed', stars: 3, maxStars: 3 }], }, } }) const slow = goldLandApi.getProgress() const fast = await goldLandApi.getProgress() assert.equal(fast.levels[0]?.title, 'New') assert.equal(fast.levels[0]?.stars, 3) await assert.rejects(slow, StaleSessionError) }) test('401 on protected route for current session invokes unauthorized handler', async () => { const { api } = await import('../src/api/client') const { setUnauthorizedHandler } = await import('../src/api/authSession') const { setAuthToken, getAuthToken } = await import('../src/api/token') let cleared = false setUnauthorizedHandler(() => { cleared = true }) setAuthToken('live-token') globalThis.routeHandlers.set('/me', () => ({ status: 401, body: { error: 'Unauthorized' } })) await assert.rejects(api.getMe(), (e: Error) => e.name === 'ApiUnauthorizedError') assert.equal(cleared, true) assert.equal(getAuthToken(), null) }) test('wrong login password does not invoke global unauthorized handler', async () => { const { api } = await import('../src/api/client') const { setUnauthorizedHandler } = await import('../src/api/authSession') const { setAuthToken, getAuthToken } = await import('../src/api/token') let cleared = false setUnauthorizedHandler(() => { cleared = true }) setAuthToken(null) globalThis.routeHandlers.set('/auth/login', () => ({ status: 401, body: { error: 'نام کاربری یا رمز عبور اشتباه است' }, })) await assert.rejects(api.login('demo', 'wrong'), (e: Error) => e.name === 'ApiUnauthorizedError') assert.equal(cleared, false) assert.equal(getAuthToken(), null) }) test('network error on bootstrap getMe preserves token', async () => { const { api } = await import('../src/api/client') const { setAuthToken, getAuthToken } = await import('../src/api/token') const { ApiNetworkError } = await import('../src/api/errors') setAuthToken('persist-me') globalThis.routeHandlers.set('/me', () => { throw new TypeError('fetch failed') }) await assert.rejects(api.getMe(), ApiNetworkError) assert.equal(getAuthToken(), 'persist-me') }) test('logout resets user, wallet, games stores and progress sync', async () => { const { applyMeResponse, resetUserStores } = await import('../src/api/sync') const { useUserStore } = await import('../src/stores/user') const { useWalletStore } = await import('../src/stores/wallet') const { useGamesStore } = await import('../src/stores/games') const { goldLandApi } = await import('../src/modules/gold-land/services/goldLand.api') applyMeResponse(createMeFixture({ xp: 42, coins: 200, totalStars: 6 })) assert.equal(useUserStore().xp, 42) assert.equal(useWalletStore().cash, 500) globalThis.routeHandlers.set('/gold-land/progress', () => ({ status: 200, body: { levels: [{ id: 1, title: 'X', status: 'completed', stars: 3, maxStars: 3 }] }, })) await goldLandApi.getProgress() resetUserStores() assert.equal(useUserStore().xp, 0) assert.equal(useUserStore().coins, 0) assert.equal(useWalletStore().cash, 0) assert.equal(useGamesStore().totalStars, 0) let call = 0 globalThis.routeHandlers.set('/gold-land/progress', () => { call += 1 if (call === 1) { return { status: 200, body: { levels: [{ id: 1, title: 'Stale', status: 'available', stars: 0, maxStars: 3 }] }, delayMs: 20, } } return { status: 200, body: { levels: [{ id: 1, title: 'Fresh', status: 'completed', stars: 3, maxStars: 3 }] }, } }) const stale = goldLandApi.getProgress() resetUserStores() const fresh = await goldLandApi.getProgress() assert.equal(fresh.levels[0]?.title, 'Fresh') const { StaleSessionError } = await import('../src/api/errors') await assert.rejects(stale, StaleSessionError) })