198 lines
6.4 KiB
TypeScript
198 lines
6.4 KiB
TypeScript
/**
|
|||
|
|
* Stage 4 — store / games / daily challenge API wiring and stale-response policy.
|
||
|
|
*/
|
||
|
|
import assert from 'node:assert/strict'
|
||
|
|
import test from 'node:test'
|
||
|
|
import { createPinia, setActivePinia } from 'pinia'
|
||
|
|
import { createMeFixture } from './fixtures/me.fixture'
|
||
|
|
import { installFetchMock, resetAppTestState, type MockResponse } from './helpers/fetchMock'
|
||
|
|
|
||
|
|
test.beforeEach(async () => {
|
||
|
|
installFetchMock()
|
||
|
|
await resetAppTestState()
|
||
|
|
setActivePinia(createPinia())
|
||
|
|
const { resetUserStores } = await import('../src/api/sync')
|
||
|
|
resetUserStores()
|
||
|
|
})
|
||
|
|
|
||
|
|
test('store catalog loads products from GET /store/products', async () => {
|
||
|
|
const products = [
|
||
|
|
{ id: 'castle', title: 'قلعه', price: 5000, category: 'special', emoji: '🏰' },
|
||
|
|
]
|
||
|
|
globalThis.routeHandlers!.set('/store/products', () => ({ status: 200, body: products }))
|
||
|
|
|
||
|
|
const { useStoreCatalogStore } = await import('../src/stores/storeCatalog')
|
||
|
|
const store = useStoreCatalogStore()
|
||
|
|
await store.loadProducts()
|
||
|
|
assert.equal(store.loadStatus, 'ready')
|
||
|
|
assert.equal(store.products.length, 1)
|
||
|
|
assert.equal(store.products[0]?.id, 'castle')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('store GET error surfaces retryable error state', async () => {
|
||
|
|
globalThis.routeHandlers!.set('/store/products', () => ({
|
||
|
|
status: 500,
|
||
|
|
body: { error: 'خطای سرور' },
|
||
|
|
}))
|
||
|
|
|
||
|
|
const { useStoreCatalogStore } = await import('../src/stores/storeCatalog')
|
||
|
|
const store = useStoreCatalogStore()
|
||
|
|
await store.loadProducts()
|
||
|
|
assert.equal(store.loadStatus, 'error')
|
||
|
|
assert.ok(store.loadError)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('successful purchase applies server me without optimistic coin change', async () => {
|
||
|
|
const meBefore = createMeFixture({ coins: 1000 })
|
||
|
|
const meAfter = createMeFixture({ coins: 500 })
|
||
|
|
meAfter.progress.ownedItems = ['castle']
|
||
|
|
|
||
|
|
const { applyMeResponse } = await import('../src/api/sync')
|
||
|
|
const { useUserStore } = await import('../src/stores/user')
|
||
|
|
const { useStoreCatalogStore } = await import('../src/stores/storeCatalog')
|
||
|
|
|
||
|
|
applyMeResponse(meBefore)
|
||
|
|
assert.equal(useUserStore().coins, 1000)
|
||
|
|
|
||
|
|
globalThis.routeHandlers!.set('/store/purchases', () => ({
|
||
|
|
status: 200,
|
||
|
|
body: meAfter,
|
||
|
|
delayMs: 15,
|
||
|
|
}))
|
||
|
|
|
||
|
|
const store = useStoreCatalogStore()
|
||
|
|
const purchase = store.purchaseProduct('castle')
|
||
|
|
assert.equal(useUserStore().coins, 1000)
|
||
|
|
await purchase
|
||
|
|
assert.equal(useUserStore().coins, 500)
|
||
|
|
assert.ok(useUserStore().coins !== 1000)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('failed purchase does not change coins', async () => {
|
||
|
|
const me = createMeFixture({ coins: 200 })
|
||
|
|
const { applyMeResponse } = await import('../src/api/sync')
|
||
|
|
const { useUserStore } = await import('../src/stores/user')
|
||
|
|
const { useStoreCatalogStore } = await import('../src/stores/storeCatalog')
|
||
|
|
|
||
|
|
applyMeResponse(me)
|
||
|
|
globalThis.routeHandlers!.set('/store/purchases', () => ({
|
||
|
|
status: 400,
|
||
|
|
body: { error: 'سکه کافی نداری!' },
|
||
|
|
}))
|
||
|
|
|
||
|
|
const store = useStoreCatalogStore()
|
||
|
|
const { ApiClientError } = await import('../src/api/errors')
|
||
|
|
await assert.rejects(store.purchaseProduct('castle'), ApiClientError)
|
||
|
|
assert.equal(useUserStore().coins, 200)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('stale store catalog GET after logout does not repopulate products', async () => {
|
||
|
|
const { invalidateSession } = await import('../src/api/authSession')
|
||
|
|
const { useStoreCatalogStore } = await import('../src/stores/storeCatalog')
|
||
|
|
const { StaleSessionError } = await import('../src/api/errors')
|
||
|
|
|
||
|
|
globalThis.routeHandlers!.set('/store/products', () => ({
|
||
|
|
status: 200,
|
||
|
|
body: [{ id: 'x', title: 'X', price: 1, category: 'boost', emoji: '⚡' }],
|
||
|
|
delayMs: 30,
|
||
|
|
}))
|
||
|
|
|
||
|
|
const store = useStoreCatalogStore()
|
||
|
|
const load = store.loadProducts()
|
||
|
|
invalidateSession()
|
||
|
|
await load
|
||
|
|
assert.equal(store.products.length, 0)
|
||
|
|
assert.equal(store.loadStatus, 'idle')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('queued purchases apply in server order even when first HTTP response is slower', async () => {
|
||
|
|
const me1 = createMeFixture({ coins: 100 })
|
||
|
|
const me2 = createMeFixture({ coins: 250 })
|
||
|
|
|
||
|
|
const { applyMeResponse, runMeMutation } = await import('../src/api/sync')
|
||
|
|
const { useUserStore } = await import('../src/stores/user')
|
||
|
|
|
||
|
|
applyMeResponse(createMeFixture({ coins: 50 }))
|
||
|
|
|
||
|
|
let purchaseCall = 0
|
||
|
|
globalThis.routeHandlers!.set('/store/purchases', () => {
|
||
|
|
purchaseCall += 1
|
||
|
|
if (purchaseCall === 1) {
|
||
|
|
return { status: 200, body: me1, delayMs: 35 }
|
||
|
|
}
|
||
|
|
return { status: 200, body: me2 }
|
||
|
|
})
|
||
|
|
|
||
|
|
const { api } = await import('../src/api/client')
|
||
|
|
|
||
|
|
await Promise.all([
|
||
|
|
runMeMutation(() => api.purchaseProduct('castle')),
|
||
|
|
runMeMutation(() => api.purchaseProduct('suit')),
|
||
|
|
])
|
||
|
|
assert.equal(useUserStore().coins, 250)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('games catalog load uses GET /games', async () => {
|
||
|
|
globalThis.routeHandlers!.set('/games', () => ({
|
||
|
|
status: 200,
|
||
|
|
body: [
|
||
|
|
{
|
||
|
|
id: 'gold-guess',
|
||
|
|
title: 'حدس',
|
||
|
|
description: 'd',
|
||
|
|
icon: '📈',
|
||
|
|
color: '#000',
|
||
|
|
route: '/games/gold-guess',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}))
|
||
|
|
|
||
|
|
const { useGamesStore } = await import('../src/stores/games')
|
||
|
|
const games = useGamesStore()
|
||
|
|
await games.loadCatalog()
|
||
|
|
assert.equal(games.catalogLoadStatus, 'ready')
|
||
|
|
assert.equal(games.catalog[0]?.id, 'gold-guess')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('daily challenge complete returns server feedback only after POST', async () => {
|
||
|
|
const me = createMeFixture({ coins: 10 })
|
||
|
|
const { applyMeResponse } = await import('../src/api/sync')
|
||
|
|
applyMeResponse(me)
|
||
|
|
|
||
|
|
globalThis.routeHandlers!.set('/challenges/daily', () => ({
|
||
|
|
status: 200,
|
||
|
|
body: {
|
||
|
|
id: 'daily-challenge',
|
||
|
|
question: 'Q?',
|
||
|
|
coinReward: 50,
|
||
|
|
xpReward: 30,
|
||
|
|
completed: false,
|
||
|
|
options: [{ id: 'gold', label: 'طلا', icon: '🥇', color: '#f5b62d' }],
|
||
|
|
},
|
||
|
|
}))
|
||
|
|
|
||
|
|
globalThis.routeHandlers!.set('/challenges/daily/complete', () => ({
|
||
|
|
status: 200,
|
||
|
|
body: {
|
||
|
|
...me,
|
||
|
|
user: { ...me.user, coins: 60 },
|
||
|
|
feedback: 'بازخورد سرور',
|
||
|
|
alreadyCompleted: false,
|
||
|
|
},
|
||
|
|
}))
|
||
|
|
|
||
|
|
const { useDailyChallengeStore } = await import('../src/stores/dailyChallenge')
|
||
|
|
const { useUserStore } = await import('../src/stores/user')
|
||
|
|
const challenge = useDailyChallengeStore()
|
||
|
|
await challenge.loadChallenge()
|
||
|
|
const result = await challenge.completeOption('gold')
|
||
|
|
assert.equal(result.feedback, 'بازخورد سرور')
|
||
|
|
assert.equal(challenge.lastFeedback, 'بازخورد سرور')
|
||
|
|
assert.equal(useUserStore().coins, 60)
|
||
|
|
})
|
||
|
|
|
||
|
|
declare global {
|
||
|
|
// eslint-disable-next-line no-var
|
||
|
|
var routeHandlers: Map<string, (init?: RequestInit) => MockResponse | Promise<MockResponse>>
|
||
|
|
}
|