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