Files
gold_land_server/test/concurrency.test.ts
safavimohadeseh78andCursor 866be22395
Backend CI / type-check, build, tests (push) Successful in 1m16s
feat: complete the thirteen-stage financial learning path
Server grades levels 7-13 and keeps castle attempt results separate from stored progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 19:41:24 +03:30

295 lines
10 KiB
TypeScript

/**
* Concurrency / RMW safety — temp SQLite only.
* Run: npm run test:concurrency
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { DatabaseService } from '../src/database/database.service'
import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader'
import { loadLessonForLevel } from '../src/gold-land/lessons/lesson.loader'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-concurrency-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3997'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
function parseJson<T>(payload: string): T {
return JSON.parse(payload) as T
}
function authHeader(token: string) {
return { authorization: `Bearer ${token}` }
}
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
)
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
async function registerUser(app: NestFastifyApplication, username: string): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username, password: '123456', name: username },
})
assert.ok(res.statusCode === 200 || res.statusCode === 201, `register status ${res.statusCode}`)
return parseJson<{ token: string }>(res.payload).token
}
function setUserCoins(db: DatabaseService, userId: string, coins: number) {
const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as
| { state_json: string }
| undefined
assert.ok(row)
const state = JSON.parse(row.state_json) as ReturnType<typeof createDefaultUserState>
state.coins = coins
db.getDb()
.prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?')
.run(JSON.stringify(state), new Date().toISOString(), userId)
}
function setUserCash(db: DatabaseService, userId: string, cash: number, goldGrams = 0) {
const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as
| { state_json: string }
| undefined
assert.ok(row)
const state = JSON.parse(row.state_json) as ReturnType<typeof createDefaultUserState>
state.cash = cash
state.goldGrams = goldGrams
db.getDb()
.prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?')
.run(JSON.stringify(state), new Date().toISOString(), userId)
}
function readCoins(db: DatabaseService, userId: string): number {
const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as {
state_json: string
}
return (JSON.parse(row.state_json) as { coins: number }).coins
}
function countLedger(db: DatabaseService, userId: string, rewardKey: string): number {
const row = db
.getDb()
.prepare('SELECT COUNT(*) AS c FROM reward_ledger WHERE user_id = ? AND reward_key = ?')
.get(userId, rewardKey) as { c: number }
return row.c
}
async function runTests() {
const app = await createApp()
const db = app.get(DatabaseService)
try {
// --- parallel store purchases: combined cost exceeds balance ---
{
const token = await registerUser(app, `store-race-${Date.now()}`)
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
setUserCoins(db, userId, 6000)
const [castle, crown] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: authHeader(token),
payload: { productId: 'castle' },
}),
app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: authHeader(token),
payload: { productId: 'crown' },
}),
])
const okCount = [castle, crown].filter((r) => r.statusCode === 200).length
const failCount = [castle, crown].filter((r) => r.statusCode >= 400).length
assert.equal(okCount, 1)
assert.equal(failCount, 1)
const finalCoins = readCoins(db, userId)
assert.ok(finalCoins >= 0)
assert.ok(finalCoins < 6000)
}
// --- parallel lesson completes: first-completion ledger once ---
{
const token = await registerUser(app, `lesson-race-${Date.now()}`)
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
await app.inject({
method: 'POST',
url: '/api/gold-land/levels/1/start',
headers: authHeader(token),
})
const lesson = loadLessonForLevel('what-is-money')
assert.ok(lesson)
const answers = getGradableQuestions(lesson).map(({ questionId, step }) => {
if (step.type === 'sort') {
return { questionId, orderedItemIds: [...step.correctOrder] }
}
return { questionId, answerId: step.correctAnswerId }
})
const payload = JSON.stringify({ answers, durationMs: 3000 })
const [a, b] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/1/complete',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload,
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/1/complete',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload,
}),
])
assert.ok(a.statusCode === 200 || b.statusCode === 200)
assert.equal(countLedger(db, userId, 'level:1:first-completion'), 1)
}
// --- parallel daily challenge completes ---
{
const token = await registerUser(app, `challenge-race-${Date.now()}`)
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
const coinsBefore = readCoins(db, userId)
const [a, b] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: authHeader(token),
payload: { optionId: 'gold' },
}),
app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: authHeader(token),
payload: { optionId: 'gold' },
}),
])
assert.equal(a.statusCode, 200)
assert.equal(b.statusCode, 200)
const coinsAfter = readCoins(db, userId)
assert.equal(coinsAfter, coinsBefore + 50)
}
// --- parallel gold buys: only one should succeed when cash fits one ---
{
const token = await registerUser(app, `wallet-race-${Date.now()}`)
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
const { GOLD_PRICE } = await import('../src/shared/catalog/market.constants')
setUserCash(db, userId, GOLD_PRICE * 2, 0)
const [buy1, buy2] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(token),
payload: { grams: 2 },
}),
app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(token),
payload: { grams: 2 },
}),
])
const results = [buy1, buy2]
const ok = results.filter((r) => r.statusCode === 200).length
const bad = results.filter((r) => r.statusCode >= 400).length
assert.equal(ok, 1)
assert.equal(bad, 1)
const state = JSON.parse(
(db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as {
state_json: string
}).state_json,
) as { cash: number; goldGrams: number }
assert.ok(state.cash >= 0)
assert.equal(state.goldGrams, 2)
assert.ok(state.cash < GOLD_PRICE * 2)
}
// --- parallel game complete: first-completion reward once ---
{
const token = await registerUser(app, `game-race-${Date.now()}`)
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string; coins: number; xp: number } }>(me.payload).user
const coinsBefore = userId.coins
const xpBefore = userId.xp
const [g1, g2] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: authHeader(token),
payload: {},
}),
app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: authHeader(token),
payload: {},
}),
])
assert.equal(g1.statusCode, 200)
assert.equal(g2.statusCode, 200)
const after = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const body = parseJson<{ user: { coins: number; xp: number } }>(after.payload)
assert.equal(body.user.coins, coinsBefore + 20)
assert.equal(body.user.xp, xpBefore + 50)
}
// Level 7 stays locked until level 6 has a star. Inactive level 8 is covered in budgeting tests.
{
const token = await registerUser(app, `locked7-${Date.now()}`)
const res = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/start',
headers: authHeader(token),
})
assert.equal(res.statusCode, 403)
}
console.log('concurrency tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})