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
+10 -5
View File
@@ -1,14 +1,19 @@
# Server — production cutover uses PORT=3000 (replaces legacy Fastify)
# Server — production trial uses PORT=3000
PORT=3000
HOST=0.0.0.0
NODE_ENV=development
# CORS (Vue dev server)
CORS_ORIGIN=http://localhost:5173
# CORS — exact browser origin(s); comma-separated for multiple trial hosts
CORS_ORIGIN=http://localhost:5174
# SQLite — path inside container or on host (DB_PATH or DATABASE_PATH)
# SQLite — persistent path (container: /app/data/app.db)
# DB_PATH=/app/data/app.db
# DATABASE_PATH=/app/data/app.db
# JWT — must match legacy Fastify when migrating existing tokens
# JWT — required in production (min 32 chars, not a placeholder below)
JWT_SECRET=gold-land-dev-secret-change-in-production
# Only when behind a known reverse proxy (optional in development)
# TRUST_PROXY=false
# Required in production when TRUST_PROXY=true — set your proxy/Docker network CIDR(s), not empty:
# TRUST_PROXY_CIDRS=172.20.0.0/16
+39
View File
@@ -0,0 +1,39 @@
name: Backend CI
on:
push:
pull_request:
env:
NODE_VERSION: '22.18.0'
jobs:
checks:
name: type-check, build, tests
runs-on: ubuntu-latest
timeout-minutes: 25
env:
JWT_SECRET: ci-test-jwt-not-for-production
NODE_ENV: test
steps:
- name: Checkout backend
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies (npm ci)
run: npm ci
- name: Type check
run: npm run type-check
- name: Build
run: npm run build
- name: Tests (temporary SQLite only)
run: npm run test
+1
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
.idea/
data/
.env
.env.docker
+1
View File
@@ -0,0 +1 @@
22.18.0
+3 -3
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
FROM node:22.12.0-bookworm-slim AS builder
FROM node:22.18.0-bookworm-slim AS builder
WORKDIR /app
@@ -17,7 +17,7 @@ COPY src ./src
RUN npm run build
FROM node:22.12.0-bookworm-slim AS production
FROM node:22.18.0-bookworm-slim AS production
WORKDIR /app
@@ -48,6 +48,6 @@ USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD node -e "fetch('http://127.0.0.1:3000/health/ready').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/main.js"]
+132 -11
View File
@@ -6,23 +6,111 @@ Legacy Fastify `backend/` was removed in G4 (2026-08-01).
## Requirements
- Node.js **22+** (`nvm use 22.12.0`)
- **Node.js:** `^22.18.0` or `>=24.12.0` (`package.json` → `engines`)
- **Pinned for local dev / CI:** `22.18.0` (see `.nvmrc`)
```powershell
nvm install 22.18.0
nvm use 22.18.0
```
After switching Node on a new machine, ensure **better-sqlite3** matches the Node ABI:
```powershell
npm ci
# if load fails: npm rebuild better-sqlite3
```
On Linux CI/runners, native build needs `build-essential` and `python3`.
## Configuration
Copy `.env.example` to `.env` or use the checked-in local `.env` (gitignored):
Copy `.env.example` to `.env` for a new machine. Do not commit `.env`.
| Variable | Production cutover |
| Variable | Local development |
|----------|-------------------|
| `PORT` | `3000` |
| `DB_PATH` | `backend-nest/data/app.db` |
| `JWT_SECRET` | Same as legacy Fastify |
| `CORS_ORIGIN` | `http://localhost:5173` |
| `CORS_ORIGIN` | `http://localhost:5174` (Vite dev server) |
| `DB_PATH` | optional; defaults to `backend-nest/data/app.db` |
| `JWT_SECRET` | dev value in `.env.example`; required in production |
## Development
**CI/tests** use temporary SQLite paths or env overrides — never commit or upload `data/app.db`.
## Development (this repo)
```powershell
npm run dev
npm ci
npm run build # first time or after src changes if not using dev watch
npm run dev # nest start --watch
# or run compiled:
npm run start
```
- Health: http://127.0.0.1:3000/health
- API prefix: `/api/...`
## Frontend (sibling repo)
Vue app: `gold_land_user` on Gitea — dev http://localhost:5174, proxies `/api` to this backend.
## CI (Gitea Actions)
Workflow: [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) (runs on **push** / **pull_request** when **act_runner** is available — **not verified** on `get.feep.ir` from this repo alone).
### Job **checks**
```powershell
npm ci
npm run type-check
npm run build
npm run test
```
- **Node:** `22.18.0`
- **Env in job:** `NODE_ENV=test`, `JWT_SECRET=ci-test-jwt-not-for-production` (not production secrets)
- **Timeout:** 25 minutes
- **Cache:** npm optional via `actions/setup-node`
### Local equivalent
Same commands as the CI job above. Use a **clean** tree:
```powershell
Remove-Item -Recurse -Force node_modules -ErrorAction SilentlyContinue
npm ci
npm run type-check
npm run build
npm run test
```
### E2E (frontend repo)
Browser E2E lives in **frontend** (`.gitea/workflows/ci.yml` job **e2e**). It clones this repo at a **pinned ref** (`frontend/e2e/backend.contract.json` + optional variable `E2E_BACKEND_REF`). Backend CI does not run Playwright.
### Runner prerequisites
- Node **22.18.0**
- Toolchain for **better-sqlite3** native module on Linux
### Secrets
This workflow needs **no** repository secrets. Frontend E2E may use `E2E_BACKEND_CHECKOUT_TOKEN` to clone this repo if it is private.
### Not yet verified on a real runner
- Workflow execution on `get.feep.ir` with registered **act_runner**
- `npm ci` + full test suite on a clean Linux runner (validated locally in a temp checkout — see stage 7 report)
## Optional: start backend + frontend (Windows)
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\dev-local-stack.ps1
```
Test ports + temp DB (does not touch `data/app.db`):
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\dev-local-stack.ps1 -UseTestPorts -SmokeTest
```
## Production
@@ -32,10 +120,43 @@ npm run build
npm run start
```
Set `NODE_ENV=production` and a strong `JWT_SECRET` via environment or `.env` on the server.
## Tests
Uses **temporary SQLite files only** — not `data/app.db`.
```powershell
npm run type-check
npm run test:progression
npm run test:integration
npm run test
```
Includes: progression, integration, concurrency, gold-precision, lesson-rewards, dual-connection.
Removed historical `test:cutover:*` scripts — see [docs/cutover-scripts-removed.md](docs/cutover-scripts-removed.md).
## Docker
`docker-compose.yml` in this repo runs backend only. Full stack compose lives in the **frontend** repo.
Trial deploy guide (6 lessons, placeholders, backup, rollback): [../frontend/docs/DEPLOY-TRIAL.md](../frontend/docs/DEPLOY-TRIAL.md) when repos are siblings, or `frontend/docs/DEPLOY-TRIAL.md` in the user repo.
## Backup / restore (SQLite)
WAL-safe backup (not raw copy):
```powershell
$env:DB_PATH = ".\data\app.db"
$env:BACKUP_DIR = ".\backups"
$env:BACKUP_RETENTION_DAYS = "14"
npm run backup:sqlite
npm run restore:sqlite -- backups\app-....db .\restored-trial.db
```
**Live database path:** the script cannot restore directly onto configured `DB_PATH`. Stop the service, restore to a sibling file, swap files, remove stale `-wal`/`-shm`. See [docs/restore-production-db.md](docs/restore-production-db.md). `RESTORE_ACK_STOPPED=YES` is operator acknowledgment only — not a service stop detector.
## Production smoke (local, temp DB)
```powershell
npm run build
npm run smoke:trial-production
```
+17
View File
@@ -0,0 +1,17 @@
# Cutover npm scripts (removed)
Historical migration scripts lived at `scripts/cutover/` (workspace parent of `backend-nest`). That folder was not recovered after the 2026-08 incident.
The following **npm scripts were removed** from `package.json` because they pointed at missing files:
- `test:cutover:g1a`
- `test:cutover:g1b`
- `test:cutover:g1b2b`
- `test:cutover:g1b:dry-run`
**Use instead:**
- `npm run test:progression` — Gold Land progression, grader, `reward_ledger`
- `npm run test:integration` — auth, economy, API contracts (temporary SQLite only)
To recover old cutover tooling, restore from Git history or the backend remote archive; do not re-add broken script paths without the files.
+73
View File
@@ -0,0 +1,73 @@
# Restore SQLite onto the live database path
The restore script **refuses to write directly** to the path in `DB_PATH` while that variable still points at the live file. That avoids accidental overwrite with the service running.
`RESTORE_ACK_STOPPED=YES` is an **operator acknowledgment** only — the script does **not** detect whether the backend process is stopped. You must stop the service yourself before swapping files.
## Prerequisites
- Backend **stopped** (no open handles on `app.db`, `-wal`, `-shm`).
- A validated backup from `npm run backup:sqlite` (WAL-safe, schema-checked).
- Shell access to the data directory (host path or mounted volume).
## Recommended procedure (same logical database)
Replace `/app/data/app.db` with your paths.
1. **Stop backend**
```powershell
docker compose stop backend
# or: stop the Node process / systemd unit
```
2. **Optional safety backup of current file**
```powershell
$env:DB_PATH = "/app/data/app.db"
$env:BACKUP_DIR = "./backups"
npm run backup:sqlite
```
3. **Restore to a sibling file** (not the live `DB_PATH` target)
```powershell
npm run restore:sqlite -- ./backups/app-2026-01-01T12-00-00.db /app/data/app-restored.db
```
Use `RESTORE_ACK_STOPPED=YES` if `/app/data/app-restored.db` already exists from a prior attempt.
4. **Verify** (optional second check)
```powershell
node -e "import('./scripts/sqlite-app-schema.mjs').then(m=>console.log(m.validateAppSqliteFile('/app/data/app-restored.db')))"
```
5. **Swap files** (preserve previous DB on disk)
```powershell
mv /app/data/app.db /app/data/app.db.pre-restore-<timestamp>
rm -f /app/data/app.db-wal /app/data/app.db-shm
mv /app/data/app-restored.db /app/data/app.db
```
6. **Start backend** and check readiness
```powershell
docker compose start backend
curl http://127.0.0.1:<frontend-port>/health/ready
```
## What the script enforces
| Rule | Meaning |
|------|---------|
| No restore when `target === DB_PATH` | Use a sibling path, then rename after stop |
| `RESTORE_ACK_STOPPED=YES` if target exists | Confirms you intend to overwrite an existing file |
| Schema + `integrity_check` | Backup must match app migrations |
| Failed validation | Staging removed; live file unchanged |
| Existing target | Moved to `*.pre-restore-<timestamp>.bak` before replace |
## Development
Restoring to a **new temp path** does not require stopping production. See `npm run restore:sqlite -- backup.db ./tmp/restored.db`.
+17 -6
View File
@@ -8,12 +8,23 @@
"start": "node dist/main.js",
"start:prod": "node dist/main.js",
"type-check": "tsc --noEmit -p tsconfig.json",
"test:integration": "tsx test/integration.test.ts",
"test:progression": "tsx test/progression.test.ts",
"test:cutover:g1a": "tsx ../scripts/cutover/run-g1a.ts",
"test:cutover:g1b": "tsx ../scripts/cutover/run-g1b.ts",
"test:cutover:g1b2b": "tsx ../scripts/cutover/run-g1b2b.ts",
"test:cutover:g1b:dry-run": "tsx ../scripts/cutover/g1b-dry-run.ts"
"test:integration": "tsx --tsconfig tsconfig.json test/integration.test.ts",
"test:progression": "tsx --tsconfig tsconfig.json test/progression.test.ts",
"test:concurrency": "tsx --tsconfig tsconfig.json test/concurrency.test.ts",
"test:gold-precision": "tsx --tsconfig tsconfig.json test/gold-precision.test.ts",
"test:lesson-rewards": "tsx --tsconfig tsconfig.json test/lesson-rewards-api.test.ts",
"test:dual-connection": "tsx --tsconfig tsconfig.json test/dual-connection.test.ts",
"test:production-config": "tsx --tsconfig tsconfig.json test/production-config.test.ts",
"test:auth-rate-limit": "tsx --tsconfig tsconfig.json test/auth-rate-limit.test.ts",
"test:client-ip": "tsx --tsconfig tsconfig.json test/client-ip.test.ts",
"test:auth-rate-limit-xff": "tsx --tsconfig tsconfig.json test/auth-rate-limit-xff.test.ts",
"test:restore-safety": "tsx --tsconfig tsconfig.json test/restore-safety.test.ts",
"test:production-error": "tsx --tsconfig tsconfig.json test/production-error.test.ts",
"backup:sqlite": "node scripts/sqlite-backup.mjs",
"restore:sqlite": "node scripts/sqlite-restore.mjs",
"smoke:trial-production": "node scripts/trial-production-smoke.mjs",
"test:backup-restore": "tsx --tsconfig tsconfig.json test/backup-restore.test.ts",
"test": "npm run test:progression && npm run test:integration && npm run test:concurrency && npm run test:gold-precision && npm run test:lesson-rewards && npm run test:dual-connection && npm run test:production-config && npm run test:production-error && npm run test:client-ip && npm run test:auth-rate-limit && npm run test:auth-rate-limit-xff && npm run test:backup-restore && npm run test:restore-safety"
},
"dependencies": {
"@nestjs/common": "^11.0.12",
+280
View File
@@ -0,0 +1,280 @@
# Starts Child Gold Land backend + frontend for local dev.
# Manages only processes started by this script. No npm install / rebuild on each run.
#
# Usage:
# pwsh -File .\scripts\dev-local-stack.ps1
# pwsh -File .\scripts\dev-local-stack.ps1 -UseTestPorts -SmokeTest # alt ports + health/login probe then exit
#
# Default ports: backend 3000, frontend 5174. Test ports: 3010 / 5184 (when 3000/5174 are busy).
param(
[switch]$UseTestPorts,
[switch]$SmokeTest
)
$ErrorActionPreference = 'Stop'
$BackendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$WorkspaceRoot = (Resolve-Path (Join-Path $BackendRoot '..')).Path
$FrontendRoot = Join-Path $WorkspaceRoot 'frontend'
if ($UseTestPorts) {
$BackendPort = 3010
$FrontendPort = 5190
} else {
$BackendPort = 3000
$FrontendPort = 5174
}
$HealthUrl = "http://127.0.0.1:${BackendPort}/health"
$FrontendUrl = "http://localhost:${FrontendPort}/"
$LoginProxyUrl = "http://localhost:${FrontendPort}/api/auth/login"
$script:ManagedProcesses = @()
$script:TempDbDir = $null
function Write-Info([string]$Message) { Write-Host $Message -ForegroundColor Cyan }
function Write-Warn([string]$Message) { Write-Host $Message -ForegroundColor Yellow }
function Write-Err([string]$Message) { Write-Host $Message -ForegroundColor Red }
function Test-NodeEngines {
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
throw 'Node.js not found in PATH. Install Node ^22.18.0 or >=24.12.0 (see .nvmrc).'
}
$raw = (node -v).TrimStart('v')
$ver = [version]$raw
$ok = ($ver.Major -eq 22 -and $ver -ge [version]'22.18.0') -or ($ver.Major -ge 24 -and $ver -ge [version]'24.12.0')
if (-not $ok) {
throw "Node v$raw is not supported. Required: ^22.18.0 or >=24.12.0. Run: nvm install 22.18.0 && nvm use 22.18.0"
}
Write-Info "Node $(node -v) at $((Get-Command node).Source)"
}
function Test-RepoLayout {
if (-not (Test-Path (Join-Path $BackendRoot 'package.json'))) {
throw "Backend package.json not found at $BackendRoot"
}
if (-not (Test-Path (Join-Path $FrontendRoot 'package.json'))) {
throw "Frontend not found at `"$FrontendRoot`" (expected sibling of backend-nest)."
}
}
function Test-DependenciesPresent {
if (-not (Test-Path (Join-Path $BackendRoot 'node_modules'))) {
throw "Backend node_modules missing. Run once: cd `"$BackendRoot`"; npm install"
}
if (-not (Test-Path (Join-Path $FrontendRoot 'node_modules'))) {
throw "Frontend node_modules missing. Run once: cd `"$FrontendRoot`"; npm install"
}
}
function Get-ListenProcessId([int]$Port) {
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
if ($conn) { return [int]$conn.OwningProcess }
return $null
}
function Assert-PortFree([int]$Port, [string]$Label) {
$ownerPid = Get-ListenProcessId $Port
if ($null -ne $ownerPid) {
$name = (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue).ProcessName
throw "${Label} port $Port is in use (PID $ownerPid, $name). Use -UseTestPorts or free the port."
}
}
function New-BackendProcessEnvironment {
$envMap = @{}
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
$envMap[$entry.Key] = [string]$entry.Value
}
$envMap['PORT'] = [string]$BackendPort
$envMap['HOST'] = '0.0.0.0'
$envMap['NODE_ENV'] = 'development'
$envMap['CORS_ORIGIN'] = "http://localhost:${FrontendPort}"
if ($UseTestPorts) {
$script:TempDbDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cgl-devstack-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $script:TempDbDir -Force | Out-Null
$envMap['DB_PATH'] = Join-Path $script:TempDbDir 'app.db'
Write-Info "Test backend DB: $($envMap['DB_PATH'])"
}
return $envMap
}
function Start-ProcessWithEnv {
param(
[string]$FilePath,
[string[]]$ArgumentList,
[string]$WorkingDirectory,
[hashtable]$Environment
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $FilePath
$psi.WorkingDirectory = $WorkingDirectory
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
$quoted = $ArgumentList | ForEach-Object {
if ($_ -match '\s') { "`"$_`"" } else { $_ }
}
$psi.Arguments = $quoted -join ' '
}
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
$psi.EnvironmentVariables[[string]$entry.Key] = [string]$entry.Value
}
foreach ($key in $Environment.Keys) {
$psi.EnvironmentVariables[$key] = [string]$Environment[$key]
}
$proc = [System.Diagnostics.Process]::Start($psi)
if (-not $proc) {
throw "Failed to start process: $FilePath"
}
return $proc
}
function Start-Backend {
$mainJs = Join-Path $BackendRoot 'dist\main.js'
if (-not (Test-Path $mainJs)) {
throw "Missing dist/main.js. Run once: cd `"$BackendRoot`"; npm run build"
}
$node = (Get-Command node).Source
$envMap = New-BackendProcessEnvironment
Write-Info "Starting backend on port $BackendPort ..."
$p = Start-ProcessWithEnv -FilePath $node -ArgumentList @($mainJs) -WorkingDirectory $BackendRoot -Environment $envMap
$script:ManagedProcesses += $p
Start-Sleep -Milliseconds 500
if ($p.HasExited) {
throw "Backend exited immediately (exit $($p.ExitCode)). Build with current Node and check dist/main.js."
}
return $p
}
function Start-Frontend {
$npm = (Get-Command npm.cmd -ErrorAction SilentlyContinue).Source
if (-not $npm) {
$npm = (Get-Command npm -ErrorAction SilentlyContinue).Source
}
if (-not $npm) {
throw 'npm not found in PATH.'
}
$envMap = @{}
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
$envMap[$entry.Key] = [string]$entry.Value
}
$envMap['VITE_DEV_PORT'] = [string]$FrontendPort
$envMap['VITE_DEV_API_TARGET'] = "http://127.0.0.1:${BackendPort}"
Write-Info "Starting frontend on port $FrontendPort (proxy -> $($envMap['VITE_DEV_API_TARGET'])) ..."
$p = Start-ProcessWithEnv -FilePath $npm -ArgumentList @('run', 'dev', '--', '--port', [string]$FrontendPort) `
-WorkingDirectory $FrontendRoot -Environment $envMap
$script:ManagedProcesses += $p
return $p
}
function Wait-BackendHealth {
param([int]$TimeoutSeconds = 90)
Write-Info "Waiting for backend health (max ${TimeoutSeconds}s) ..."
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
foreach ($p in $script:ManagedProcesses) {
if ($p.HasExited) {
throw "Managed process exited early (PID $($p.Id), exit $($p.ExitCode))."
}
}
try {
$r = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 3
if ($r.StatusCode -eq 200) {
Write-Info "Backend health OK: $HealthUrl"
return
}
} catch {
Start-Sleep -Seconds 2
}
}
throw "Backend health timed out: $HealthUrl"
}
function Wait-FrontendHttp {
param([int]$TimeoutSeconds = 120)
Write-Info "Waiting for frontend (max ${TimeoutSeconds}s) ..."
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
try {
$r = Invoke-WebRequest -Uri $FrontendUrl -UseBasicParsing -TimeoutSec 3
if ($r.StatusCode -eq 200) {
Write-Info "Frontend OK: $FrontendUrl"
return
}
} catch {
Start-Sleep -Seconds 2
}
}
throw "Frontend HTTP timed out: $FrontendUrl"
}
function Test-ProxyLogin {
$body = '{"username":"demo","password":"123456"}'
$r = Invoke-WebRequest -Uri $LoginProxyUrl -Method POST -Body $body -ContentType 'application/json' -UseBasicParsing
if ($r.StatusCode -ne 200) {
throw "Proxy login failed: HTTP $($r.StatusCode)"
}
Write-Info "Proxy login OK: $LoginProxyUrl"
}
function Stop-ManagedProcesses {
foreach ($p in $script:ManagedProcesses) {
if ($p -and -not $p.HasExited) {
Write-Info "Stopping PID $($p.Id) ..."
try {
& taskkill.exe /PID $p.Id /T /F 2>$null | Out-Null
} catch {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
}
}
}
$script:ManagedProcesses = @()
if ($script:TempDbDir -and (Test-Path $script:TempDbDir)) {
Remove-Item -Recurse -Force $script:TempDbDir -ErrorAction SilentlyContinue
$script:TempDbDir = $null
}
}
try {
Write-Info 'Child Gold Land — local dev stack'
if ($UseTestPorts) { Write-Warn 'Using test ports 3010 / 5190 (temp SQLite when backend starts fresh DB).' }
Test-NodeEngines
Test-RepoLayout
Test-DependenciesPresent
Assert-PortFree -Port $BackendPort -Label 'Backend'
Assert-PortFree -Port $FrontendPort -Label 'Frontend'
Start-Backend | Out-Null
Start-Frontend | Out-Null
Wait-BackendHealth
if ($SmokeTest) {
Wait-FrontendHttp
Test-ProxyLogin
Write-Info 'SmokeTest passed.'
exit 0
}
Write-Host ''
Write-Info "Frontend: $FrontendUrl"
Write-Info "Backend health: $HealthUrl"
Write-Info 'Demo login: demo / 123456'
Write-Warn 'Press Ctrl+C to stop only processes started by this script.'
Write-Host ''
while ($true) {
Start-Sleep -Seconds 2
foreach ($p in $script:ManagedProcesses) {
if ($p.HasExited) {
Write-Err "Process PID $($p.Id) exited unexpectedly (code $($p.ExitCode))."
exit 1
}
}
}
} catch {
Write-Err $_.Exception.Message
exit 1
} finally {
Stop-ManagedProcesses
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Validates that a SQLite file matches Child Gold Land backend schema expectations.
*/
import Database from 'better-sqlite3'
export const EXPECTED_TABLES = [
'users',
'user_level_progress',
'reward_ledger',
'auth_accounts',
]
/** Keep in sync with src/database/migrations/index.ts */
export const EXPECTED_MIGRATION_IDS = ['001_initial_schema']
/**
* @param {string} dbPath
* @returns {{ ok: true } | { ok: false; reason: string }}
*/
export function validateAppSqliteFile(dbPath) {
let db
try {
db = new Database(dbPath, { readonly: true })
} catch (error) {
return { ok: false, reason: `cannot open database: ${error instanceof Error ? error.message : error}` }
}
try {
const integrity = db.pragma('integrity_check', { simple: true })
if (integrity !== 'ok') {
return { ok: false, reason: `integrity_check failed: ${integrity}` }
}
const tables = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'",
)
.all()
.map((r) => r.name)
for (const name of EXPECTED_TABLES) {
if (!tables.includes(name)) {
return { ok: false, reason: `missing table: ${name}` }
}
}
const migTable = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
.get()
if (!migTable) {
return { ok: false, reason: 'missing schema_migrations table' }
}
const applied = db.prepare('SELECT id FROM schema_migrations ORDER BY id').all().map((r) => r.id)
for (const id of EXPECTED_MIGRATION_IDS) {
if (!applied.includes(id)) {
return { ok: false, reason: `missing migration: ${id}` }
}
}
return { ok: true }
} finally {
db.close()
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* WAL-safe SQLite backup via better-sqlite3 backup API (not a raw file copy).
* Usage: DB_PATH=./data/app.db BACKUP_DIR=./backups node scripts/sqlite-backup.mjs
*/
import Database from 'better-sqlite3'
import { mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
const srcPath = process.env.DB_PATH ?? join(process.cwd(), 'data', 'app.db')
const backupDir = process.env.BACKUP_DIR ?? join(process.cwd(), 'backups')
const retentionDays = Number(process.env.BACKUP_RETENTION_DAYS ?? '0')
if (!existsSync(srcPath)) {
console.error(`Source database not found: ${srcPath}`)
process.exit(1)
}
mkdirSync(backupDir, { recursive: true })
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const destPath = join(backupDir, `app-${stamp}.db`)
const src = new Database(srcPath, { readonly: true })
try {
await src.backup(destPath)
} finally {
src.close()
}
const verify = new Database(destPath, { readonly: true })
try {
const integrity = verify.pragma('integrity_check', { simple: true })
if (integrity !== 'ok') {
console.error(`Backup integrity check failed: ${integrity}`)
process.exit(1)
}
} finally {
verify.close()
}
console.log(`Backup written: ${destPath}`)
if (retentionDays > 0) {
const cutoff = Date.now() - retentionDays * 86_400_000
for (const name of readdirSync(backupDir)) {
if (!name.startsWith('app-') || !name.endsWith('.db')) continue
const full = join(backupDir, name)
if (statSync(full).mtimeMs < cutoff) {
unlinkSync(full)
console.log(`Removed old backup: ${name}`)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Restore backup to a target path (service must be stopped).
* Usage: node scripts/sqlite-restore.mjs <backup.db> <target.db>
*/
import Database from 'better-sqlite3'
import {
copyFileSync,
existsSync,
mkdirSync,
renameSync,
rmSync,
statSync,
} from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { validateAppSqliteFile } from './sqlite-app-schema.mjs'
const backupPath = resolve(process.argv[2] ?? '')
const targetPath = resolve(process.argv[3] ?? '')
if (!backupPath || !targetPath) {
console.error('Usage: node scripts/sqlite-restore.mjs <backup.db> <target.db>')
process.exit(1)
}
if (!existsSync(backupPath)) {
console.error(`Backup not found: ${backupPath}`)
process.exit(1)
}
const activeDb = resolve(process.env.DB_PATH ?? join(process.cwd(), 'data', 'app.db'))
const targetExists = existsSync(targetPath)
if (targetPath === activeDb) {
console.error('Restore onto active DB_PATH is not allowed while it is configured as the live database.')
console.error('Stop the backend service, point DB_PATH elsewhere, or restore to a temp file first.')
process.exit(1)
}
if (targetExists && process.env.RESTORE_ACK_STOPPED !== 'YES') {
console.error(
'Target database file already exists. Stop the backend service and set RESTORE_ACK_STOPPED=YES to continue.',
)
process.exit(1)
}
const schema = validateAppSqliteFile(backupPath)
if (!schema.ok) {
console.error(`Backup rejected: ${schema.reason}`)
process.exit(1)
}
const staging = `${targetPath}.restore-staging`
const stagingWal = `${staging}-wal`
const stagingShm = `${staging}-shm`
function cleanupStaging() {
for (const p of [staging, stagingWal, stagingShm]) {
try {
if (existsSync(p)) rmSync(p)
} catch {
// ignore
}
}
}
cleanupStaging()
try {
copyFileSync(backupPath, staging)
const stagedCheck = validateAppSqliteFile(staging)
if (!stagedCheck.ok) {
console.error(`Staging validation failed: ${stagedCheck.reason}`)
cleanupStaging()
process.exit(1)
}
const verify = new Database(staging, { readonly: true })
try {
const integrity = verify.pragma('integrity_check', { simple: true })
if (integrity !== 'ok') {
console.error(`Staging integrity check failed: ${integrity}`)
cleanupStaging()
process.exit(1)
}
} finally {
verify.close()
}
for (const sidecar of [`${targetPath}-wal`, `${targetPath}-shm`]) {
try {
if (existsSync(sidecar)) rmSync(sidecar)
} catch {
// ignore
}
}
if (targetExists) {
const backupOfTarget = `${targetPath}.pre-restore-${Date.now()}.bak`
renameSync(targetPath, backupOfTarget)
console.log(`Existing target moved to ${backupOfTarget}`)
}
renameSync(staging, targetPath)
console.log(`Restored ${backupPath} -> ${targetPath}`)
} catch (error) {
cleanupStaging()
console.error(error instanceof Error ? error.message : error)
process.exit(1)
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Smoke-test production env on a temp DB (no deploy).
* Requires: npm run build first.
*/
import { spawn, spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
if (!existsSync(join(backendRoot, 'dist', 'main.js'))) {
console.error('Run npm run build before trial-production-smoke.mjs')
process.exit(1)
}
const tempDir = mkdtempSync(join(tmpdir(), 'cgl-prod-smoke-'))
const dbPath = join(tempDir, 'app.db')
const port = String(3020 + Math.floor(Math.random() * 100))
const secret = `trial-smoke-${'x'.repeat(40)}`
function fail(msg) {
console.error(msg)
rmSync(tempDir, { recursive: true, force: true })
process.exit(1)
}
const bad = spawnSync(process.execPath, ['dist/main.js'], {
cwd: backendRoot,
env: {
...process.env,
NODE_ENV: 'production',
PORT: port,
DB_PATH: dbPath,
JWT_SECRET: 'gold-land-dev-secret-change-in-production',
CORS_ORIGIN: 'http://127.0.0.1:8080',
},
stdio: 'pipe',
encoding: 'utf8',
timeout: 8000,
})
if (bad.status === 0) {
fail('Expected startup failure with dev JWT placeholder in production')
}
async function waitForReady() {
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['dist/main.js'], {
cwd: backendRoot,
env: {
...process.env,
NODE_ENV: 'production',
PORT: port,
DB_PATH: dbPath,
JWT_SECRET: secret,
CORS_ORIGIN: 'http://127.0.0.1:8080',
},
stdio: 'pipe',
})
const timer = setTimeout(() => {
child.kill()
reject(new Error('startup timeout'))
}, 20_000)
const poll = async () => {
try {
const live = await fetch(`http://127.0.0.1:${port}/health`)
const ready = await fetch(`http://127.0.0.1:${port}/health/ready`)
if (live.ok && ready.ok) {
clearTimeout(timer)
child.kill()
resolve(undefined)
return
}
} catch {
// retry
}
setTimeout(poll, 300)
}
setTimeout(poll, 500)
})
}
await waitForReady().catch((e) => fail(String(e)))
const demoCheck = spawnSync(
process.execPath,
[
'-e',
`const D=require('better-sqlite3');const d=new D(${JSON.stringify(dbPath)});const row=d.prepare("SELECT COUNT(*) as c FROM auth_accounts WHERE username='demo'").get();console.log(row.c);d.close();`,
],
{ cwd: backendRoot, encoding: 'utf8' },
)
const demoCount = Number(demoCheck.stdout.trim())
if (demoCount !== 0) {
fail(`Expected no demo account in production DB, found ${demoCount}`)
}
rmSync(tempDir, { recursive: true, force: true })
console.log('trial-production-smoke passed')
+4
View File
@@ -8,6 +8,10 @@ export class AuthSeedService implements OnModuleInit {
constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {}
onModuleInit(): void {
if (process.env.NODE_ENV === 'production') {
this.logger.log('Auth seed skipped in production (no demo account)')
return
}
this.auth.seedDemoAccount()
this.logger.log('Auth seed completed (demo account if missing)')
}
+8 -3
View File
@@ -1,5 +1,8 @@
import { Body, Controller, HttpCode, Inject, Post, forwardRef } from '@nestjs/common'
import { Body, Controller, HttpCode, Inject, Post, Req, forwardRef } from '@nestjs/common'
import type { FastifyRequest } from 'fastify'
import { Public } from '../common/decorators/public.decorator'
import { getClientIp } from '../common/http/client-ip'
import { assertAuthRateLimit } from '../common/rate-limit/auth-rate-limit'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { AuthService } from './auth.service'
import { LoginDto } from './dto/login.dto'
@@ -12,14 +15,16 @@ export class AuthController {
@Public()
@HttpCode(200)
@Post('register')
register(@Body(strictBodyPipe(RegisterDto)) dto: RegisterDto) {
register(@Req() req: FastifyRequest, @Body(strictBodyPipe(RegisterDto)) dto: RegisterDto) {
assertAuthRateLimit(getClientIp(req))
return this.auth.register(dto.username, dto.password, dto.name)
}
@Public()
@HttpCode(200)
@Post('login')
login(@Body(strictBodyPipe(LoginDto)) dto: LoginDto) {
login(@Req() req: FastifyRequest, @Body(strictBodyPipe(LoginDto)) dto: LoginDto) {
assertAuthRateLimit(getClientIp(req))
return this.auth.login(dto.username, dto.password)
}
}
+4
View File
@@ -99,6 +99,10 @@ export class AuthService {
}
seedDemoAccount(): void {
if (process.env.NODE_ENV === 'production') {
return
}
const demoPasswordHash = bcrypt.hashSync('123456', 10)
const demo = this.findAccountByUsername('demo')
+18 -7
View File
@@ -7,6 +7,7 @@ import {
Logger,
} from '@nestjs/common'
import type { FastifyReply } from 'fastify'
import { safeErrorMessage } from '../http/safe-log'
interface ApiErrorBody {
error: string
@@ -19,43 +20,53 @@ export class ApiExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp()
const reply = ctx.getResponse<FastifyReply>()
const isProduction = process.env.NODE_ENV === 'production'
if (exception instanceof HttpException) {
const status = exception.getStatus()
const message = this.extractMessage(exception)
const message = this.extractMessage(exception, isProduction)
reply.status(status).send({ error: message } satisfies ApiErrorBody)
return
}
this.logger.error(exception)
this.logger.error(`Unhandled error: ${safeErrorMessage(exception)}`)
reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
error: 'خطای سرور',
} satisfies ApiErrorBody)
}
private extractMessage(exception: HttpException): string {
private extractMessage(exception: HttpException, isProduction: boolean): string {
const response = exception.getResponse()
if (typeof response === 'string') {
return response
return isProduction ? this.sanitizeClientMessage(response) : response
}
if (typeof response === 'object' && response !== null) {
const body = response as Record<string, unknown>
if (typeof body.message === 'string') {
return body.message
return isProduction ? this.sanitizeClientMessage(body.message) : body.message
}
if (Array.isArray(body.message) && body.message.length > 0) {
return body.message.map(String).join('؛ ')
const joined = body.message.map(String).join('؛ ')
return isProduction ? this.sanitizeClientMessage(joined) : joined
}
if (typeof body.error === 'string') {
return body.error
return isProduction ? this.sanitizeClientMessage(body.error) : body.error
}
}
return 'درخواست نامعتبر است'
}
/** Avoid leaking SQL paths, stack fragments, or driver names to clients in production. */
private sanitizeClientMessage(message: string): string {
if (/sqlite|SQLITE|better-sqlite|ENOENT|EACCES|stack|at\s+\S+\.(ts|js)/i.test(message)) {
return 'درخواست نامعتبر است'
}
return message
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { FastifyRequest } from 'fastify'
/** Private ranges for Docker / RFC1918 — peer must match before X-Forwarded-For is used. */
const DEFAULT_TRUSTED_PROXY_CIDRS = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.0/8']
function parseCidrList(raw: string | undefined): string[] {
if (!raw?.trim()) {
if (process.env.NODE_ENV === 'production') return []
return DEFAULT_TRUSTED_PROXY_CIDRS
}
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
function ipv4ToInt(ip: string): number | null {
const parts = ip.split('.')
if (parts.length !== 4) return null
let n = 0
for (const p of parts) {
const oct = Number(p)
if (!Number.isInteger(oct) || oct < 0 || oct > 255) return null
n = (n << 8) + oct
}
return n >>> 0
}
function normalizePeerIp(raw: string | undefined): string {
if (!raw) return ''
if (raw.startsWith('::ffff:')) return raw.slice('::ffff:'.length)
return raw
}
function ipInCidr(ip: string, cidr: string): boolean {
const [net, bitsStr] = cidr.split('/')
const bits = Number(bitsStr)
if (!net || !Number.isFinite(bits) || bits < 0 || bits > 32) return false
const ipInt = ipv4ToInt(ip)
const netInt = ipv4ToInt(net)
if (ipInt === null || netInt === null) return false
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0
return (ipInt & mask) === (netInt & mask)
}
function isTrustedProxyPeer(peerIp: string, cidrs: string[]): boolean {
const ip = normalizePeerIp(peerIp)
if (!ip) return false
return cidrs.some((cidr) => ipInCidr(ip, cidr))
}
function trustProxyEnabled(): boolean {
return process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1'
}
/**
* Client IP for rate limiting.
* X-Forwarded-For is honored only when the immediate TCP peer is a trusted proxy (nginx on Docker network).
* Direct/untrusted connections ignore client-supplied XFF (mitigates header spoofing).
*/
export function getClientIp(req: FastifyRequest): string {
const peer = normalizePeerIp(req.socket.remoteAddress) || 'unknown'
if (!trustProxyEnabled()) {
return peer
}
const cidrs = parseCidrList(process.env.TRUST_PROXY_CIDRS)
if (!isTrustedProxyPeer(peer, cidrs)) {
return peer
}
const xff = req.headers['x-forwarded-for']
if (typeof xff === 'string' && xff.trim()) {
const client = xff.split(',')[0]?.trim()
if (client) return client
}
const realIp = req.headers['x-real-ip']
if (typeof realIp === 'string' && realIp.trim()) {
return realIp.trim()
}
return peer
}
export function resetClientIpConfigForTests(): void {
delete process.env.TRUST_PROXY
delete process.env.TRUST_PROXY_CIDRS
}
+27
View File
@@ -0,0 +1,27 @@
const SENSITIVE_KEY = /password|authorization|token|secret|jwt|cookie/i
export function sanitizeForLog(value: unknown): unknown {
if (value === null || value === undefined) return value
if (typeof value === 'string') {
if (value.length > 200 && /^Bearer\s+/i.test(value)) return '[Bearer redacted]'
return value
}
if (Array.isArray(value)) return value.map(sanitizeForLog)
if (typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (SENSITIVE_KEY.test(k)) {
out[k] = '[redacted]'
} else {
out[k] = sanitizeForLog(v)
}
}
return out
}
return value
}
export function safeErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
+29
View File
@@ -0,0 +1,29 @@
import { HttpException, HttpStatus } from '@nestjs/common'
type Bucket = { count: number; resetAt: number }
const buckets = new Map<string, Bucket>()
export function resetAuthRateLimitForTests(): void {
buckets.clear()
}
const WINDOW_MS = 60_000
const MAX_ATTEMPTS = 30
/** In-memory limit for login/register per client key (IP). Suitable for single-instance trial deploy. */
export function assertAuthRateLimit(clientKey: string): void {
const key = clientKey.trim() || 'unknown'
const now = Date.now()
let bucket = buckets.get(key)
if (!bucket || now >= bucket.resetAt) {
bucket = { count: 0, resetAt: now + WINDOW_MS }
buckets.set(key, bucket)
}
bucket.count += 1
if (bucket.count > MAX_ATTEMPTS) {
throw new HttpException('تعداد درخواست زیاد است؛ کمی بعد دوباره تلاش کن.', HttpStatus.TOO_MANY_REQUESTS)
}
}
+43 -5
View File
@@ -1,6 +1,15 @@
import { plainToInstance } from 'class-transformer'
import { IsIn, IsInt, IsOptional, IsString, Min, validateSync } from 'class-validator'
const DEV_JWT_FALLBACK = 'gold-land-dev-secret-change-in-production'
const FORBIDDEN_PRODUCTION_JWT_SECRETS = new Set([
DEV_JWT_FALLBACK,
'e2e-local-jwt-not-for-production',
'e2e-ci-jwt-not-for-production',
'ci-test-jwt-not-for-production',
])
export class EnvironmentVariables {
@IsIn(['development', 'production', 'test'])
NODE_ENV!: 'development' | 'production' | 'test'
@@ -27,14 +36,42 @@ export class EnvironmentVariables {
@IsOptional()
@IsString()
JWT_SECRET?: string
/** When `true`, Fastify trusts X-Forwarded-* (only enable behind a known reverse proxy). */
@IsOptional()
@IsString()
TRUST_PROXY?: string
}
function assertProductionJwtSecret(secret: string): void {
const trimmed = secret.trim()
if (trimmed.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters when NODE_ENV=production')
}
if (FORBIDDEN_PRODUCTION_JWT_SECRETS.has(trimmed)) {
throw new Error('JWT_SECRET is a known development placeholder — set a unique production secret')
}
}
export function validateEnv(config: Record<string, unknown>): EnvironmentVariables {
const nodeEnv = (config.NODE_ENV as string | undefined) ?? 'development'
const jwtSecret = config.JWT_SECRET as string | undefined
if (nodeEnv === 'production' && (!jwtSecret || jwtSecret.trim().length === 0)) {
throw new Error('JWT_SECRET is required when NODE_ENV=production')
const trustProxyEnabled =
config.TRUST_PROXY === 'true' ||
config.TRUST_PROXY === '1' ||
config.TRUST_PROXY === true
if (nodeEnv === 'production') {
if (!jwtSecret || jwtSecret.trim().length === 0) {
throw new Error('JWT_SECRET is required when NODE_ENV=production')
}
assertProductionJwtSecret(jwtSecret)
if (trustProxyEnabled && !(config.TRUST_PROXY_CIDRS as string | undefined)?.trim()) {
throw new Error(
'TRUST_PROXY_CIDRS is required when TRUST_PROXY is enabled in production (set the reverse-proxy network CIDR, not broad defaults)',
)
}
}
if (nodeEnv !== 'production' && !jwtSecret) {
@@ -45,11 +82,12 @@ export function validateEnv(config: Record<string, unknown>): EnvironmentVariabl
const normalized = {
NODE_ENV: nodeEnv,
PORT: Number(config.PORT ?? 3001),
PORT: Number(config.PORT ?? 3000),
HOST: (config.HOST as string | undefined) ?? '0.0.0.0',
CORS_ORIGIN: (config.CORS_ORIGIN as string | undefined) ?? 'http://localhost:5173',
CORS_ORIGIN: (config.CORS_ORIGIN as string | undefined) ?? 'http://localhost:5174',
DB_PATH: (config.DB_PATH ?? config.DATABASE_PATH) as string | undefined,
JWT_SECRET: jwtSecret ?? 'gold-land-dev-secret-change-in-production',
JWT_SECRET: jwtSecret ?? DEV_JWT_FALLBACK,
TRUST_PROXY: (config.TRUST_PROXY as string | undefined) ?? 'false',
}
const validated = plainToInstance(EnvironmentVariables, normalized, {
+12 -2
View File
@@ -23,7 +23,16 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy {
mkdirSync(dirname(this.dbPath), { recursive: true })
this.db = new Database(this.dbPath)
this.db.pragma('journal_mode = WAL')
const applied = runSchemaMigrations(this.db)
this.db.pragma('busy_timeout = 5000')
let applied: string[]
try {
applied = runSchemaMigrations(this.db)
} catch (error) {
this.logger.error('SQLite migration failed', error instanceof Error ? error.stack : error)
throw new Error(
`SQLite migration failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
if (applied.length > 0) {
this.logger.log(`Applied migrations: ${applied.join(', ')}`)
}
@@ -49,8 +58,9 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy {
return this.db
}
/** IMMEDIATE acquires the write lock before reads — avoids lost updates on state_json RMW. */
transaction<T>(fn: () => T): T {
return this.getDb().transaction(fn)()
return this.getDb().transaction(fn).immediate()
}
verifyMigrationsApplied(): { ok: boolean; tables: string[]; missing: string[] } {
+44 -41
View File
@@ -173,10 +173,12 @@ export class GoldLandProgressionService {
}
syncUserProgressionState(userId: string): UserState {
const state = this.users.getUserState(userId)
const items = this.buildLevelItems(userId)
this.syncLegacyMapLevels(state, items)
return this.users.saveUserState(state)
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
const items = this.buildLevelItems(userId)
this.syncLegacyMapLevels(state, items)
return this.users.saveUserState(state)
})
}
getGoldLandProgress(userId: string) {
@@ -238,38 +240,40 @@ export class GoldLandProgressionService {
}
startLevel(userId: string, levelId: number) {
this.assertLevelPlayable(userId, levelId)
return this.database.transaction(() => {
this.assertLevelPlayable(userId, levelId)
const definition = getLevelDefinition(levelId)
if (!definition?.isActive) {
throw new ProgressionError(403, 'این مرحله به‌زودی باز می‌شود')
}
const definition = getLevelDefinition(levelId)
if (!definition?.isActive) {
throw new ProgressionError(403, 'این مرحله به‌زودی باز می‌شود')
}
const now = new Date().toISOString()
const existing = this.getProgressRow(userId, levelId)
const db = this.database.getDb()
const now = new Date().toISOString()
const existing = this.getProgressRow(userId, levelId)
const db = this.database.getDb()
if (existing?.completed_at) {
db.prepare(`
UPDATE user_level_progress
SET attempts_count = attempts_count + 1, started_at = ?, updated_at = ?
WHERE user_id = ? AND level_id = ?
`).run(now, now, userId, levelId)
} else if (existing) {
db.prepare(`
UPDATE user_level_progress
SET started_at = COALESCE(started_at, ?), updated_at = ?
WHERE user_id = ? AND level_id = ?
`).run(now, now, userId, levelId)
} else {
db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, updated_at
) VALUES (?, ?, 0, 0, 1, ?, ?)
`).run(userId, levelId, now, now)
}
if (existing?.completed_at) {
db.prepare(`
UPDATE user_level_progress
SET attempts_count = attempts_count + 1, started_at = ?, updated_at = ?
WHERE user_id = ? AND level_id = ?
`).run(now, now, userId, levelId)
} else if (existing) {
db.prepare(`
UPDATE user_level_progress
SET started_at = COALESCE(started_at, ?), updated_at = ?
WHERE user_id = ? AND level_id = ?
`).run(now, now, userId, levelId)
} else {
db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, updated_at
) VALUES (?, ?, 0, 0, 1, ?, ?)
`).run(userId, levelId, now, now)
}
return { levelId, status: 'in_progress' as const }
return { levelId, status: 'in_progress' as const }
})
}
completeLevel(
@@ -290,16 +294,15 @@ export class GoldLandProgressionService {
throw new ProgressionError(400, 'امتیاز کافی برای تکمیل مرحله نبود. دوباره تلاش کن!')
}
const existing = this.getProgressRow(userId, levelId)
const previousStars = (existing?.stars ?? 0) as 0 | 1 | 2 | 3
const previousBestScore = existing?.best_score ?? 0
const bestScore = Math.max(previousBestScore, score)
const bestScoreImproved = score > previousBestScore
const finalStars = Math.max(previousStars, newStars) as 0 | 1 | 2 | 3
const isFirstCompletion = !existing?.completed_at
const now = new Date().toISOString()
return this.database.transaction(() => {
const existing = this.getProgressRow(userId, levelId)
const previousStars = (existing?.stars ?? 0) as 0 | 1 | 2 | 3
const previousBestScore = existing?.best_score ?? 0
const bestScore = Math.max(previousBestScore, score)
const bestScoreImproved = score > previousBestScore
const finalStars = Math.max(previousStars, newStars) as 0 | 1 | 2 | 3
const isFirstCompletion = !existing?.completed_at
const now = new Date().toISOString()
const db = this.database.getDb()
if (existing) {
+21 -1
View File
@@ -1,9 +1,13 @@
import { Controller, Get } from '@nestjs/common'
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'
import { Public } from '../common/decorators/public.decorator'
import { DatabaseService } from '../database/database.service'
@Public()
@Controller()
export class HealthController {
constructor(private readonly database: DatabaseService) {}
/** Process is up (liveness). Does not verify database. */
@Get('health')
getHealth() {
return {
@@ -12,4 +16,20 @@ export class HealthController {
runtime: 'nest',
}
}
/** DB migrations applied (readiness for traffic / compose depends_on). */
@Get('health/ready')
getReady() {
const check = this.database.verifyMigrationsApplied()
if (!check.ok) {
throw new ServiceUnavailableException({
status: 'not_ready',
missingTables: check.missing,
})
}
return {
status: 'ready',
service: 'child-gold-land-backend',
}
}
}
+2
View File
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '../database/database.module'
import { HealthController } from './health.controller'
@Module({
imports: [DatabaseModule],
controllers: [HealthController],
})
export class HealthModule {}
+13 -2
View File
@@ -6,10 +6,21 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify
import { AppModule } from './app.module'
import { ApiExceptionFilter } from './common/filters/api-exception.filter'
function parseCorsOrigins(raw: string): string | string[] {
const parts = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (parts.length === 0) return 'http://localhost:5174'
if (parts.length === 1) return parts[0]!
return parts
}
async function bootstrap() {
// Rate-limit IP: getClientIp() + TRUST_PROXY_CIDRS — not global Fastify trustProxy.
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
new FastifyAdapter({ trustProxy: false }),
)
app.enableShutdownHooks()
@@ -17,7 +28,7 @@ async function bootstrap() {
const config = app.get(ConfigService)
app.enableCors({
origin: config.get<string>('CORS_ORIGIN'),
origin: parseCorsOrigins(config.get<string>('CORS_ORIGIN') ?? 'http://localhost:5174'),
credentials: true,
})
+6 -4
View File
@@ -46,10 +46,12 @@ export class ProgressionSyncService {
) {}
syncUserProgressionState(userId: string): UserState {
const state = this.users.getUserState(userId)
const items = this.buildLevelItems(userId)
this.syncLegacyMapLevels(state, items)
return this.users.saveUserState(state)
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
const items = this.buildLevelItems(userId)
this.syncLegacyMapLevels(state, items)
return this.users.saveUserState(state)
})
}
private getProgressRow(userId: string, levelId: number): LevelProgressRow | undefined {
+6 -4
View File
@@ -90,10 +90,12 @@ export class UsersService {
}
updateProfile(userId: string, name: string): MeResponse {
const state = this.getUserState(userId)
state.name = name.trim()
this.saveUserState(state)
return this.toMeResponse(state)
return this.database.transaction(() => {
const state = this.getUserState(userId)
state.name = name.trim()
this.saveUserState(state)
return this.toMeResponse(state)
})
}
private parseStateJson(raw: string, userId: string): UserState {
+4 -1
View File
@@ -1,6 +1,9 @@
import { IsNumber } from 'class-validator'
import { IsNumber, Max, Min } from 'class-validator'
import { MAX_GOLD_TRADE_GRAMS, MIN_GOLD_TRADE_GRAMS } from '../gold-trade.helpers'
export class BuyGoldDto {
@IsNumber({ allowNaN: false, allowInfinity: false })
@Min(MIN_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
@Max(MAX_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
grams!: number
}
+4 -1
View File
@@ -1,6 +1,9 @@
import { IsNumber } from 'class-validator'
import { IsNumber, Max, Min } from 'class-validator'
import { MAX_GOLD_TRADE_GRAMS, MIN_GOLD_TRADE_GRAMS } from '../gold-trade.helpers'
export class SellGoldDto {
@IsNumber({ allowNaN: false, allowInfinity: false })
@Min(MIN_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
@Max(MAX_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
grams!: number
}
+113
View File
@@ -0,0 +1,113 @@
import { BadRequestException } from '@nestjs/common'
import { GOLD_PRICE } from '../shared/catalog/market.constants'
/**
* Gold trade policy (Child Gold Land):
* - cash: integer tomans in user state
* - goldGrams: grams stored with at most 6 decimal places (1 µg = 10⁻⁶ g)
* - buy notional: ceil(grams × GOLD_PRICE) tomans (no rounding arbitrage vs sell)
* - sell notional: floor(grams × GOLD_PRICE) tomans
* - tomans computed via integer microGrams × GOLD_PRICE (BigInt) when product exceeds 2⁵³
* - maximum grams per request: 10_000
*/
export const GOLD_GRAMS_DECIMALS = 6
export const MICROGRAMS_PER_GRAM = 1_000_000
export const MAX_GOLD_TRADE_GRAMS = 10_000
export const GOLD_PRICE_TOMAN_PER_GRAM = GOLD_PRICE
/** Smallest trade size allowed by DTO (6 decimal grams). Min tomans checked separately for buy/sell. */
export const MIN_GOLD_TRADE_GRAMS = 0.000_001
const MAX_MICROGRAMS = MAX_GOLD_TRADE_GRAMS * MICROGRAMS_PER_GRAM
export function normalizeGoldGrams(grams: number): number {
const factor = 10 ** GOLD_GRAMS_DECIMALS
return Math.round(grams * factor) / factor
}
export function gramsToMicroGrams(normalizedGrams: number): number {
return Math.round(normalizedGrams * MICROGRAMS_PER_GRAM)
}
/** Integer-safe tomans numerator: microGrams × GOLD_PRICE (may exceed Number.MAX_SAFE_INTEGER). */
function tomansNumeratorMicro(microGrams: number): bigint {
return BigInt(microGrams) * BigInt(GOLD_PRICE_TOMAN_PER_GRAM)
}
export function tomansForGoldPurchase(grams: number): number {
const normalized = normalizeGoldGrams(grams)
const micro = gramsToMicroGrams(normalized)
const num = tomansNumeratorMicro(micro)
return Number((num + BigInt(MICROGRAMS_PER_GRAM - 1)) / BigInt(MICROGRAMS_PER_GRAM))
}
export function tomansForGoldSale(grams: number): number {
const micro = gramsToMicroGrams(normalizeGoldGrams(grams))
const num = tomansNumeratorMicro(micro)
return Number(num / BigInt(MICROGRAMS_PER_GRAM))
}
/** @deprecated Symmetric round — only for documenting pre-fix regression; do not use in trades. */
export function tomansForGoldGramsRound(grams: number): number {
return Math.round(normalizeGoldGrams(grams) * GOLD_PRICE_TOMAN_PER_GRAM)
}
function validateTradeGramsInput(grams: number): number {
if (typeof grams !== 'number' || !Number.isFinite(grams)) {
throw new BadRequestException('مقدار نامعتبر است')
}
if (grams <= 0) {
throw new BadRequestException('مقدار نامعتبر است')
}
if (grams > MAX_GOLD_TRADE_GRAMS) {
throw new BadRequestException('مقدار نامعتبر است')
}
const normalized = normalizeGoldGrams(grams)
if (Math.abs(grams - normalized) > 1 / 10 ** (GOLD_GRAMS_DECIMALS + 2)) {
throw new BadRequestException('دقت مقدار طلا بیش از حد مجاز است')
}
const micro = gramsToMicroGrams(normalized)
if (micro <= 0 || micro > MAX_MICROGRAMS) {
throw new BadRequestException('مقدار نامعتبر است')
}
return normalized
}
function assertMinPurchaseTomans(normalized: number): void {
if (tomansForGoldPurchase(normalized) < 1) {
throw new BadRequestException('مبلغ معامله کمتر از یک تومان است')
}
}
function assertMinSaleTomans(normalized: number): void {
if (tomansForGoldSale(normalized) < 1) {
throw new BadRequestException('مبلغ فروش کمتر از یک تومان است')
}
}
export function applyGoldPurchase(state: { cash: number; goldGrams: number }, grams: number): number {
const normalized = validateTradeGramsInput(grams)
assertMinPurchaseTomans(normalized)
const cost = tomansForGoldPurchase(normalized)
if (state.cash < cost) {
throw new BadRequestException('موجودی نقد کافی نیست')
}
state.cash -= cost
state.goldGrams = normalizeGoldGrams(state.goldGrams + normalized)
return cost
}
export function applyGoldSale(state: { cash: number; goldGrams: number }, grams: number): number {
const normalized = validateTradeGramsInput(grams)
assertMinSaleTomans(normalized)
if (normalizeGoldGrams(state.goldGrams) < normalized) {
throw new BadRequestException('طلای کافی نداری')
}
const value = tomansForGoldSale(normalized)
state.goldGrams = normalizeGoldGrams(state.goldGrams - normalized)
state.cash += value
return value
}
+4 -26
View File
@@ -1,9 +1,9 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'
import { Inject, Injectable } from '@nestjs/common'
import { DatabaseService } from '../database/database.service'
import { GOLD_PRICE } from '../shared/catalog/market.constants'
import type { MeResponse, Transaction } from '../shared/types/user.types'
import { UsersService } from '../users/users.service'
import { addTransaction, incrementMission, unlockAchievement } from '../shared/economy/user-economy.helpers'
import { applyGoldPurchase, applyGoldSale } from './gold-trade.helpers'
@Injectable()
export class WalletService {
@@ -20,18 +20,7 @@ export class WalletService {
buyGold(userId: string, grams: number): MeResponse {
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
const pricePerGram = GOLD_PRICE
const cost = grams * pricePerGram
if (grams <= 0) {
throw new BadRequestException('مقدار نامعتبر است')
}
if (state.cash < cost) {
throw new BadRequestException('موجودی نقد کافی نیست')
}
state.cash -= cost
state.goldGrams += grams
const cost = applyGoldPurchase(state, grams)
addTransaction(state, {
type: 'expense',
title: 'خرید طلا',
@@ -48,18 +37,7 @@ export class WalletService {
sellGold(userId: string, grams: number): MeResponse {
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
const pricePerGram = GOLD_PRICE
const value = grams * pricePerGram
if (grams <= 0) {
throw new BadRequestException('مقدار نامعتبر است')
}
if (state.goldGrams < grams) {
throw new BadRequestException('طلای کافی نداری')
}
state.goldGrams -= grams
state.cash += value
const value = applyGoldSale(state, grams)
addTransaction(state, {
type: 'income',
title: 'فروش طلا',
+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')
+3
View File
@@ -1,4 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false
},
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}