feat: complete server-backed six-lesson app and trial tooling
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Mutation queue — server-freshness vs response arrival order (same tab).
|
||||
*/
|
||||
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 } from './helpers/fetchMock'
|
||||
|
||||
test.beforeEach(async () => {
|
||||
installFetchMock()
|
||||
await resetAppTestState()
|
||||
setActivePinia(createPinia())
|
||||
const { resetUserStores } = await import('../src/api/sync')
|
||||
resetUserStores()
|
||||
})
|
||||
|
||||
test('uncoordinated late applyMe simulates stale server overwrite regression', async () => {
|
||||
const { applyMeResponse } = await import('../src/api/sync')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
|
||||
applyMeResponse(createMeFixture({ coins: 10 }))
|
||||
const newer = createMeFixture({ coins: 200 })
|
||||
const older = createMeFixture({ coins: 50 })
|
||||
|
||||
applyMeResponse(newer)
|
||||
applyMeResponse(older)
|
||||
assert.equal(useUserStore().coins, 50, 'older server snapshot must not win when applied last')
|
||||
})
|
||||
|
||||
test('serial queue: slower older server response then newer mutation keeps final newer state', async () => {
|
||||
const meOlder = createMeFixture({ coins: 50 })
|
||||
const meNewer = createMeFixture({ coins: 200 })
|
||||
|
||||
const { applyMeResponse, runMeMutation } = await import('../src/api/sync')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const { api } = await import('../src/api/client')
|
||||
|
||||
applyMeResponse(createMeFixture({ coins: 10 }))
|
||||
|
||||
let call = 0
|
||||
globalThis.routeHandlers!.set('/store/purchases', () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
return { status: 200, body: meOlder, delayMs: 35 }
|
||||
}
|
||||
return { status: 200, body: meNewer }
|
||||
})
|
||||
|
||||
const first = runMeMutation(() => api.purchaseProduct('castle'))
|
||||
const second = runMeMutation(() => api.purchaseProduct('suit'))
|
||||
await Promise.all([first, second])
|
||||
|
||||
assert.equal(useUserStore().coins, 200)
|
||||
})
|
||||
|
||||
test('failed mutation does not block the next mutation in queue', async () => {
|
||||
const meOk = createMeFixture({ coins: 180 })
|
||||
const { applyMeResponse, runMeMutation } = await import('../src/api/sync')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const { api } = await import('../src/api/client')
|
||||
const { ApiClientError } = await import('../src/api/errors')
|
||||
|
||||
applyMeResponse(createMeFixture({ coins: 10 }))
|
||||
|
||||
let call = 0
|
||||
globalThis.routeHandlers!.set('/store/purchases', () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
return { status: 400, body: { error: 'سکه کافی نداری!' } }
|
||||
}
|
||||
return { status: 200, body: meOk }
|
||||
})
|
||||
|
||||
await assert.rejects(runMeMutation(() => api.purchaseProduct('castle')), ApiClientError)
|
||||
await runMeMutation(() => api.purchaseProduct('suit'))
|
||||
assert.equal(useUserStore().coins, 180)
|
||||
})
|
||||
|
||||
test('logout prevents queued mutation from applying or sending after session change', async () => {
|
||||
const meStale = createMeFixture({ coins: 999 })
|
||||
const meCurrent = createMeFixture({ coins: 40 })
|
||||
|
||||
const { applyMeResponse, runMeMutation } = await import('../src/api/sync')
|
||||
const { invalidateSession } = await import('../src/api/authSession')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const { api } = await import('../src/api/client')
|
||||
const { StaleSessionError } = await import('../src/api/errors')
|
||||
|
||||
applyMeResponse(createMeFixture({ coins: 10 }))
|
||||
|
||||
globalThis.routeHandlers!.set('/store/purchases', () => ({
|
||||
status: 200,
|
||||
body: meStale,
|
||||
delayMs: 40,
|
||||
}))
|
||||
|
||||
const pending = runMeMutation(() => api.purchaseProduct('castle'))
|
||||
invalidateSession()
|
||||
applyMeResponse(meCurrent)
|
||||
|
||||
await assert.rejects(pending, StaleSessionError)
|
||||
assert.equal(useUserStore().coins, 40)
|
||||
})
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var routeHandlers: Map<string, (init?: RequestInit) => import('./helpers/fetchMock').MockResponse | Promise<import('./helpers/fetchMock').MockResponse>>
|
||||
}
|
||||
Reference in New Issue
Block a user