Files
gold_land_server/test/gold-precision.test.ts
safavimohadeseh78andCursor dfb20ca92c
Backend CI / type-check, build, tests (push) Successful in 1m0s
feat: prepare six-lesson trial backend with reliable rewards and operations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:35:42 +03:30

277 lines
9.8 KiB
TypeScript

/**
* 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)
})