291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
/**
|
|
* 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<string, (init?: RequestInit) => import('./helpers/fetchMock').MockResponse | Promise<import('./helpers/fetchMock').MockResponse>>
|
|
}
|
|
|
|
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)
|
|
})
|