feat: prepare six-lesson trial backend with reliable rewards and operations
Backend CI / type-check, build, tests (push) Successful in 1m0s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 14:35:42 +03:30
co-authored by Cursor
parent 5e5a14030a
commit dfb20ca92c
46 changed files with 2589 additions and 123 deletions
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import type { FastifyRequest } from 'fastify'
import { getClientIp } from '../src/common/http/client-ip'
import {
assertAuthRateLimit,
resetAuthRateLimitForTests,
} from '../src/common/rate-limit/auth-rate-limit'
resetAuthRateLimitForTests()
process.env.TRUST_PROXY = 'true'
const attackerPeer = '203.0.113.99'
const req = {
socket: { remoteAddress: attackerPeer },
headers: { 'x-forwarded-for': 'fresh-spoofed-ip-1' },
} as FastifyRequest
const ip = getClientIp(req)
assert.equal(ip, attackerPeer, 'forged XFF must not become rate-limit key')
for (let i = 0; i < 30; i++) {
assertAuthRateLimit(ip)
}
let blocked = false
try {
assertAuthRateLimit(ip)
blocked = true
} catch (error) {
assert.equal((error as { status?: number }).status, 429)
}
assert.equal(blocked, false, 'rate limit must apply to real peer IP')
resetAuthRateLimitForTests()
delete process.env.TRUST_PROXY
console.log('auth-rate-limit-xff tests passed')
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict'
import { assertAuthRateLimit } from '../src/common/rate-limit/auth-rate-limit'
let threw = false
for (let i = 0; i < 35; i++) {
try {
assertAuthRateLimit('test-client-429')
} catch (error) {
threw = true
assert.equal((error as { status?: number }).status, 429)
break
}
}
assert.equal(threw, true, 'expected rate limit after repeated auth attempts')
console.log('auth-rate-limit tests passed')
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import Database from 'better-sqlite3'
import { up, migrationId } from '../src/database/migrations/001-initial-schema'
const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
const root = mkdtempSync(join(tmpdir(), 'cgl-backup-'))
const srcDb = join(root, 'source.db')
const backupDir = join(root, 'backups')
const restoreTarget = join(root, 'restored.db')
try {
const db = new Database(srcDb)
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
`)
db.exec(up)
db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run(
migrationId,
new Date().toISOString(),
)
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
'u1',
'{}',
new Date().toISOString(),
)
db.close()
let r = spawnSync('node', ['scripts/sqlite-backup.mjs'], {
cwd: backendRoot,
env: { ...process.env, DB_PATH: srcDb, BACKUP_DIR: backupDir },
encoding: 'utf8',
})
assert.equal(r.status, 0, r.stderr || r.stdout)
const backupFile = readdirSync(backupDir).find((f) => f.endsWith('.db'))
assert.ok(backupFile, 'backup file created')
r = spawnSync(
'node',
['scripts/sqlite-restore.mjs', join(backupDir, backupFile!), restoreTarget],
{
cwd: backendRoot,
env: { ...process.env, RESTORE_ACK_STOPPED: 'YES' },
encoding: 'utf8',
},
)
assert.equal(r.status, 0, r.stderr || r.stdout)
assert.ok(existsSync(restoreTarget))
const restored = new Database(restoreTarget, { readonly: true })
const row = restored.prepare('SELECT id FROM users WHERE id = ?').get('u1') as { id: string }
restored.close()
assert.equal(row.id, 'u1')
} finally {
try {
rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 200 })
} catch {
// Windows may briefly lock SQLite files after close.
}
}
console.log('backup-restore tests passed')
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import type { FastifyRequest } from 'fastify'
import { getClientIp, resetClientIpConfigForTests } from '../src/common/http/client-ip'
function mockReq(peer: string, headers: Record<string, string | undefined> = {}): FastifyRequest {
return {
socket: { remoteAddress: peer },
headers,
} as FastifyRequest
}
resetClientIpConfigForTests()
process.env.TRUST_PROXY = 'true'
const untrustedPeer = '203.0.113.50'
const trustedPeer = '172.20.0.5'
assert.equal(
getClientIp(mockReq(untrustedPeer, { 'x-forwarded-for': '1.2.3.4' })),
untrustedPeer,
'untrusted peer must ignore forged XFF',
)
assert.equal(
getClientIp(mockReq(trustedPeer, { 'x-forwarded-for': '198.51.100.10' })),
'198.51.100.10',
'trusted proxy peer may use first XFF hop',
)
delete process.env.TRUST_PROXY
assert.equal(
getClientIp(mockReq(trustedPeer, { 'x-forwarded-for': '198.51.100.10' })),
trustedPeer,
'TRUST_PROXY off ignores XFF even from docker peer',
)
console.log('client-ip tests passed')
+294
View File
@@ -0,0 +1,294 @@
/**
* 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)
}
// --- coming_soon level cannot start ---
{
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)
})
+97
View File
@@ -0,0 +1,97 @@
/**
* Two independent SQLite connections on one file — one writer wins, no corrupt cash.
* Run: npm run test:dual-connection
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import Database from 'better-sqlite3'
import { runSchemaMigrations } from '../src/database/migration.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-dual-sqlite-'))
const dbPath = join(testDir, 'dual.db')
const userId = 'dual-user'
function seedUser(db: Database.Database) {
const state = createDefaultUserState(userId)
state.cash = 1000
state.goldGrams = 0
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
userId,
JSON.stringify(state),
new Date().toISOString(),
)
}
function readCash(): number {
const db = new Database(dbPath, { readonly: true })
const row = db.prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as {
state_json: string
}
db.close()
return (JSON.parse(row.state_json) as { cash: number }).cash
}
function spendInWorker(amount: number): Promise<'ok' | 'fail'> {
return new Promise((resolve, reject) => {
const worker = new Worker(
`
const Database = require('better-sqlite3');
const { parentPort, workerData } = require('worker_threads');
const db = new Database(workerData.dbPath);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
const spend = db.transaction(() => {
const row = db.prepare('SELECT state_json FROM users WHERE id = ?').get(workerData.userId);
const state = JSON.parse(row.state_json);
if (state.cash < workerData.amount) return false;
state.cash -= workerData.amount;
db.prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?').run(
JSON.stringify(state),
new Date().toISOString(),
workerData.userId,
);
return true;
}).immediate;
parentPort.postMessage(spend() ? 'ok' : 'fail');
db.close();
`,
{ eval: true, workerData: { dbPath, userId, amount } },
)
worker.on('message', (msg) => resolve(msg as 'ok' | 'fail'))
worker.on('error', reject)
})
}
async function runTests() {
const bootstrap = new Database(dbPath)
bootstrap.pragma('journal_mode = WAL')
runSchemaMigrations(bootstrap)
seedUser(bootstrap)
bootstrap.close()
const [a, b] = await Promise.all([spendInWorker(700), spendInWorker(700)])
const outcomes = [a, b]
assert.equal(outcomes.filter((x) => x === 'ok').length, 1)
assert.equal(outcomes.filter((x) => x === 'fail').length, 1)
const finalCash = readCash()
assert.equal(finalCash, 300)
assert.ok(finalCash >= 0)
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
console.log('dual-connection tests passed')
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})
+276
View File
@@ -0,0 +1,276 @@
/**
* Gold trade precision + no rounding-arbitrage — temp SQLite only.
* Run: npm run test:gold-precision
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe, BadRequestException } 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 {
applyGoldPurchase,
applyGoldSale,
gramsToMicroGrams,
normalizeGoldGrams,
tomansForGoldGramsRound,
tomansForGoldPurchase,
tomansForGoldSale,
GOLD_PRICE_TOMAN_PER_GRAM,
MAX_GOLD_TRADE_GRAMS,
} from '../src/wallet/gold-trade.helpers'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
import { GOLD_PRICE } from '../src/shared/catalog/market.constants'
import { DatabaseService } from '../src/database/database.service'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-gold-precision-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3996'
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)
return parseJson<{ token: string }>(res.payload).token
}
function assertNoCashArbitrage(initialCash: number, finalCash: number) {
assert.ok(
finalCash <= initialCash,
`cash must not increase from rounding (initial=${initialCash}, final=${finalCash})`,
)
}
function unitTests() {
assert.equal(GOLD_PRICE_TOMAN_PER_GRAM, GOLD_PRICE)
// Pre-fix symmetric round produced +1 toman on this split (5 buy, 6 sell).
assert.equal(tomansForGoldGramsRound(0.000_003), 5)
assert.equal(tomansForGoldGramsRound(0.000_001), 2)
assert.equal(tomansForGoldPurchase(0.000_003), 6)
assert.equal(tomansForGoldSale(0.000_001), 1)
assert.equal(tomansForGoldPurchase(0.000_001), 2)
assert.equal(tomansForGoldPurchase(1), GOLD_PRICE)
assert.equal(tomansForGoldSale(1), GOLD_PRICE)
assert.equal(tomansForGoldPurchase(0.05), Math.ceil(0.05 * GOLD_PRICE))
assert.equal(tomansForGoldSale(0.05), Math.floor(0.05 * GOLD_PRICE))
assert.throws(() => applyGoldPurchase({ cash: 1e12, goldGrams: 0 }, Number.POSITIVE_INFINITY), BadRequestException)
assert.throws(() => applyGoldPurchase({ cash: 1e12, goldGrams: 0 }, 0), BadRequestException)
assert.throws(() => applyGoldPurchase({ cash: 1e12, goldGrams: 0 }, 0.000_000_4), BadRequestException)
const maxMicro = gramsToMicroGrams(MAX_GOLD_TRADE_GRAMS)
assert.ok(maxMicro * GOLD_PRICE > Number.MAX_SAFE_INTEGER)
assert.equal(tomansForGoldPurchase(MAX_GOLD_TRADE_GRAMS), MAX_GOLD_TRADE_GRAMS * GOLD_PRICE)
const state = { cash: 10_000_000, goldGrams: 42.5 }
const cost = applyGoldPurchase(state, 0.05)
assert.equal(cost, Math.ceil(0.05 * GOLD_PRICE))
assert.equal(state.goldGrams, normalizeGoldGrams(42.5 + 0.05))
applyGoldSale(state, 0.001)
assert.equal(state.goldGrams, normalizeGoldGrams(42.5 + 0.05 - 0.001))
// 3 µg buy then three 1 µg sells — no cash gain.
const arb = createDefaultUserState('arb')
arb.cash = 1000
arb.goldGrams = 0
applyGoldPurchase(arb, 0.000_003)
applyGoldSale(arb, 0.000_001)
applyGoldSale(arb, 0.000_001)
applyGoldSale(arb, 0.000_001)
assert.equal(arb.goldGrams, 0)
assertNoCashArbitrage(1000, arb.cash)
// Aggregated sell vs split sells — same gold out, cash never above start.
const agg = createDefaultUserState('agg')
agg.cash = 500_000
agg.goldGrams = 0
const aggStart = agg.cash
applyGoldPurchase(agg, 0.000_003)
applyGoldSale(agg, 0.000_003)
assert.equal(agg.goldGrams, 0)
assertNoCashArbitrage(aggStart, agg.cash)
// Many micro cycles at fixed rate.
const repeat = createDefaultUserState('u')
const startCash = GOLD_PRICE * 100
repeat.cash = startCash
repeat.goldGrams = 0
for (let i = 0; i < 50; i++) {
applyGoldPurchase(repeat, 0.000_001)
applyGoldSale(repeat, 0.000_001)
}
assert.equal(repeat.goldGrams, 0)
assertNoCashArbitrage(startCash, repeat.cash)
// Buy small chunks, sell in one lump.
const lump = createDefaultUserState('lump')
lump.cash = 1_000_000
lump.goldGrams = 0
const lumpStart = lump.cash
for (let i = 0; i < 10; i++) {
applyGoldPurchase(lump, 0.000_001)
}
applyGoldSale(lump, 0.000_01)
assert.equal(lump.goldGrams, 0)
assertNoCashArbitrage(lumpStart, lump.cash)
}
async function seedWallet(
app: NestFastifyApplication,
token: string,
cash: number,
goldGrams: number,
): Promise<string> {
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
const db = app.get(DatabaseService)
const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as {
state_json: string
}
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)
return userId
}
async function apiRegressionScenario(app: NestFastifyApplication) {
const token = await registerUser(app, `gold-round-${Date.now()}`)
const initialCash = 1000
await seedWallet(app, token, initialCash, 0)
const buy = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.000_003 }),
})
assert.equal(buy.statusCode, 200)
const afterBuy = parseJson<{ wallet: { cash: number; goldGrams: number } }>(buy.payload)
assert.equal(afterBuy.wallet.goldGrams, 0.000_003)
assert.equal(afterBuy.wallet.cash, initialCash - tomansForGoldPurchase(0.000_003))
let cash = afterBuy.wallet.cash
let gold = afterBuy.wallet.goldGrams
for (let i = 0; i < 3; i++) {
const sell = await app.inject({
method: 'POST',
url: '/api/wallet/gold/sell',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.000_001 }),
})
assert.equal(sell.statusCode, 200)
const body = parseJson<{ wallet: { cash: number; goldGrams: number } }>(sell.payload)
cash = body.wallet.cash
gold = body.wallet.goldGrams
}
assert.equal(gold, 0)
assertNoCashArbitrage(initialCash, cash)
assert.notEqual(cash, initialCash + 1, 'old symmetric round would have created +1 toman')
}
async function apiTests(app: NestFastifyApplication) {
await apiRegressionScenario(app)
const token = await registerUser(app, `gold-precision-${Date.now()}`)
await seedWallet(app, token, 500_000, 0)
const subToman = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.000_000_5 }),
})
assert.equal(subToman.statusCode, 400)
const highPrecision = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.000_001_5 }),
})
assert.equal(highPrecision.statusCode, 400)
assert.equal(parseJson<{ error: string }>(highPrecision.payload).error, 'دقت مقدار طلا بیش از حد مجاز است')
const userId = await seedWallet(app, token, 500_000, 0)
const db = app.get(DatabaseService)
const stateBeforeFail = (
db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as { state_json: string }
).state_json
const insufficient = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(token),
payload: { grams: 1000 },
})
assert.equal(insufficient.statusCode, 400)
const stateAfterFail = (
db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as { state_json: string }
).state_json
assert.equal(stateAfterFail, stateBeforeFail)
const buy = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.000_001 }),
})
assert.equal(buy.statusCode, 200)
const buyBody = parseJson<{ wallet: { cash: number; goldGrams: number } }>(buy.payload)
assert.equal(buyBody.wallet.goldGrams, 0.000_001)
assert.equal(buyBody.wallet.cash, 500_000 - tomansForGoldPurchase(0.000_001))
}
async function runTests() {
unitTests()
const app = await createApp()
try {
await apiTests(app)
console.log('gold-precision tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})
+1 -1
View File
@@ -1825,7 +1825,7 @@ async function runTests() {
catalogSnapshot,
)
const { createDefaultUserState: createDefaultUserStateF0 } = await import(
const { createDefaultUserState } = await import(
'../src/shared/catalog/default-user-state'
)
const defaultState1 = createDefaultUserState('f4-default-1')
+211
View File
@@ -0,0 +1,211 @@
/**
* Lesson reward rules via HTTP — four replay scenarios on level 6 (inflation).
* Run: npm run test:lesson-rewards
*/
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 { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader'
import type { LessonDefinition, LessonSubmissionAnswer } from '../src/gold-land/lessons/lesson.types'
import { inflationLesson } from '../src/gold-land/lessons/inflation.lesson'
import { DatabaseService } from '../src/database/database.service'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-lesson-rewards-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3995'
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}` }
}
function buildPartialCorrect(lesson: LessonDefinition, correctCount: number): LessonSubmissionAnswer[] {
const gradable = getGradableQuestions(lesson)
return gradable.map(({ questionId, step }, index) => {
const isCorrect = index < correctCount
if (step.type === 'sort') {
const order = isCorrect ? [...step.correctOrder] : [...step.correctOrder].reverse()
return { questionId, orderedItemIds: order }
}
if (!isCorrect) {
const wrong =
step.type === 'true_false'
? step.correctAnswerId === 'true'
? 'false'
: 'true'
: step.options.find((o) => o.id !== step.correctAnswerId)?.id ?? 'wrong'
return { questionId, answerId: wrong }
}
return { questionId, answerId: step.correctAnswerId }
})
}
function buildAllCorrect(lesson: LessonDefinition): LessonSubmissionAnswer[] {
return getGradableQuestions(lesson).map(({ questionId, step }) => {
if (step.type === 'sort') {
return { questionId, orderedItemIds: [...step.correctOrder] }
}
return { questionId, answerId: step.correctAnswerId }
})
}
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): Promise<{ token: string; userId: string }> {
const username = `lesson-rewards-${Date.now()}`
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)
const token = parseJson<{ token: string }>(res.payload).token
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
return { token, userId }
}
function unlockThroughLevel5(db: DatabaseService, userId: string) {
const now = new Date().toISOString()
for (let levelId = 1; levelId <= 5; levelId++) {
db.getDb().prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 65, 1, 1, ?, ?, ?)
`).run(userId, levelId, now, now, now)
}
}
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
}
type CompletePayload = {
levelId: number
score: number
stars: number
previousStars: number
isFirstCompletion: boolean
bestScoreImproved: boolean
reward: { xp: number; coins: number }
me: { user: { xp: number; coins: number }; progress: { totalStars: number } }
}
async function completeLevel(
app: NestFastifyApplication,
token: string,
answers: LessonSubmissionAnswer[],
): Promise<CompletePayload> {
const res = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/complete',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ answers, durationMs: 4000 }),
})
assert.equal(res.statusCode, 200, res.payload)
return parseJson<CompletePayload>(res.payload)
}
async function runTests() {
const app = await createApp()
const db = app.get(DatabaseService)
try {
const { token, userId } = await registerUser(app)
unlockThroughLevel5(db, userId)
await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/start',
headers: authHeader(token),
})
const THREE = buildPartialCorrect(inflationLesson, 3)
const ALL = buildAllCorrect(inflationLesson)
const FOUR = buildPartialCorrect(inflationLesson, 4)
const me0 = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const base = parseJson<{ user: { xp: number; coins: number } }>(me0.payload)
// 1) First completion with 1★ (score 60)
const first = await completeLevel(app, token, THREE)
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.previousStars, 0)
assert.equal(first.isFirstCompletion, true)
assert.equal(first.bestScoreImproved, true)
assert.equal(first.reward.xp, 40 + 5)
assert.equal(first.reward.coins, 15 + 3)
assert.equal(countLedger(db, userId, 'level:6:first-completion'), 1)
assert.equal(countLedger(db, userId, 'level:6:star:1'), 1)
assert.equal(first.me.progress.mapLevels.find((l) => l.id === 6)?.stars, 1)
// 2) Repeat same score — no extra reward
const repeat = await completeLevel(app, token, THREE)
assert.equal(repeat.stars, 1)
assert.equal(repeat.isFirstCompletion, false)
assert.equal(repeat.bestScoreImproved, false)
assert.equal(repeat.reward.xp, 0)
assert.equal(repeat.reward.coins, 0)
assert.equal(repeat.me.user.xp, base.user.xp + first.reward.xp)
assert.equal(repeat.me.user.coins, base.user.coins + first.reward.coins)
// 3) Improve 1★ → 3★
const improved = await completeLevel(app, token, ALL)
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.previousStars, 1)
assert.equal(improved.bestScoreImproved, true)
assert.equal(improved.reward.xp, 5 + 5)
assert.equal(improved.reward.coins, 3 + 3)
assert.equal(countLedger(db, userId, 'level:6:star:2'), 1)
assert.equal(countLedger(db, userId, 'level:6:star:3'), 1)
// 4) Lower run after best — stars stay 3, no reward
const lower = await completeLevel(app, token, FOUR)
assert.equal(lower.score, 80)
assert.equal(lower.stars, 3)
assert.equal(lower.previousStars, 3)
assert.equal(lower.bestScoreImproved, false)
assert.equal(lower.reward.xp, 0)
assert.equal(lower.reward.coins, 0)
console.log('lesson-rewards-api tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})
+67
View File
@@ -0,0 +1,67 @@
import 'reflect-metadata'
import assert from 'node:assert/strict'
import { validateEnv } from '../src/config/env.validation'
const VALID_PROD_SECRET = 'a'.repeat(48)
assert.throws(
() =>
validateEnv({
NODE_ENV: 'production',
PORT: 3000,
HOST: '0.0.0.0',
CORS_ORIGIN: 'https://app.example.com',
JWT_SECRET: '',
}),
/JWT_SECRET is required/,
)
assert.throws(
() =>
validateEnv({
NODE_ENV: 'production',
PORT: 3000,
HOST: '0.0.0.0',
CORS_ORIGIN: 'https://app.example.com',
JWT_SECRET: 'gold-land-dev-secret-change-in-production',
}),
/placeholder/,
)
assert.throws(
() =>
validateEnv({
NODE_ENV: 'production',
PORT: 3000,
HOST: '0.0.0.0',
CORS_ORIGIN: 'https://app.example.com',
JWT_SECRET: 'short',
}),
/32 characters/,
)
assert.throws(
() =>
validateEnv({
NODE_ENV: 'production',
PORT: 3000,
HOST: '0.0.0.0',
CORS_ORIGIN: 'https://app.example.com',
JWT_SECRET: VALID_PROD_SECRET,
TRUST_PROXY: 'true',
}),
/TRUST_PROXY_CIDRS is required/,
)
const ok = validateEnv({
NODE_ENV: 'production',
PORT: 3000,
HOST: '0.0.0.0',
CORS_ORIGIN: 'https://app.example.com',
JWT_SECRET: VALID_PROD_SECRET,
TRUST_PROXY: 'true',
TRUST_PROXY_CIDRS: '172.20.0.0/16',
})
assert.equal(ok.NODE_ENV, 'production')
console.log('production-config tests passed')
+46
View File
@@ -0,0 +1,46 @@
import 'reflect-metadata'
import assert from 'node:assert/strict'
import { HttpException, HttpStatus } from '@nestjs/common'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
const filter = new ApiExceptionFilter()
function runFilter(exception: unknown): { status: number; body: { error: string } } {
let status = 0
let body: { error: string } = { error: '' }
const host = {
switchToHttp: () => ({
getResponse: () => ({
status(code: number) {
status = code
return {
send(payload: { error: string }) {
body = payload
},
}
},
}),
}),
}
filter.catch(exception, host as never)
return { status, body }
}
const prev = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
const internal = runFilter(
new Error('SqliteError: constraint at /secret/path/app.db\n at foo.ts:1:1'),
)
assert.equal(internal.status, HttpStatus.INTERNAL_SERVER_ERROR)
assert.equal(internal.body.error, 'خطای سرور')
assert.ok(!JSON.stringify(internal.body).includes('app.db'))
const httpErr = runFilter(
new HttpException('SQLITE_BUSY: database is locked', HttpStatus.BAD_REQUEST),
)
assert.equal(httpErr.body.error, 'درخواست نامعتبر است')
process.env.NODE_ENV = prev
console.log('production-error tests passed')
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import Database from 'better-sqlite3'
import { up, migrationId } from '../src/database/migrations/001-initial-schema'
const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
const root = mkdtempSync(join(tmpdir(), 'cgl-restore-safety-'))
function runRestore(backup: string, target: string, env: Record<string, string> = {}) {
return spawnSync('node', ['scripts/sqlite-restore.mjs', backup, target], {
cwd: backendRoot,
env: { ...process.env, ...env },
encoding: 'utf8',
})
}
function seedAppDb(path: string) {
const db = new Database(path)
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
`)
db.exec(up)
db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run(
migrationId,
new Date().toISOString(),
)
db.close()
}
try {
const goodBackup = join(root, 'good.db')
seedAppDb(goodBackup)
const wrongSchema = join(root, 'wrong.db')
const w = new Database(wrongSchema)
w.exec('CREATE TABLE users (id TEXT PRIMARY KEY);')
w.close()
writeFileSync(join(root, 'corrupt.db'), 'not-a-sqlite-file')
let r = runRestore(join(root, 'corrupt.db'), join(root, 't1.db'))
assert.notEqual(r.status, 0)
r = runRestore(wrongSchema, join(root, 't2.db'))
assert.notEqual(r.status, 0)
const existing = join(root, 'existing.db')
seedAppDb(existing)
const before = readFileSync(existing)
r = runRestore(goodBackup, existing, {})
assert.notEqual(r.status, 0)
assert.deepEqual(readFileSync(existing), before, 'failed restore must not change target')
const freshTarget = join(root, 'fresh.db')
r = runRestore(goodBackup, freshTarget, { RESTORE_ACK_STOPPED: 'YES' })
assert.equal(r.status, 0, r.stderr || r.stdout)
assert.ok(existsSync(freshTarget))
const liveConfigured = join(root, 'live-configured.db')
seedAppDb(liveConfigured)
r = runRestore(goodBackup, liveConfigured, {
DB_PATH: liveConfigured,
RESTORE_ACK_STOPPED: 'YES',
})
assert.notEqual(r.status, 0, 'must refuse restore onto configured live DB_PATH')
} finally {
try {
rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 200 })
} catch {
// Windows may briefly lock SQLite files after close.
}
}
console.log('restore-safety tests passed')