feat: prepare six-lesson trial backend with reliable rewards and operations
Backend CI / type-check, build, tests (push) Successful in 1m0s
Backend CI / type-check, build, tests (push) Successful in 1m0s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user