Backend CI / type-check, build, tests (push) Successful in 1m16s
Server grades levels 7-13 and keeps castle attempt results separate from stored progress. Co-authored-by: Cursor <cursoragent@cursor.com>
295 lines
10 KiB
TypeScript
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)
|
|
})
|