/** * 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(payload: string): T { return JSON.parse(payload) as T } function authHeader(token: string) { return { authorization: `Bearer ${token}` } } async function createApp(): Promise { const { AppModule } = await import('../src/app.module') const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() const app = moduleRef.createNestApplication(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 { 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 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 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) })