/** * 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 MockResponse | Promise> }