Files
gold_land_user/test/session-sync.test.ts
safavimohadeseh78andCursor e3f0457dc9
Frontend CI / unit (test, type-check, build) (push) Successful in 1m11s
Frontend CI / browser E2E (frontend + pinned backend) (push) Failing after 2m48s
feat: complete server-backed six-lesson app and trial tooling
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:40:37 +03:30

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)
})