/** * 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(payload: string): T { return JSON.parse(payload) as T } function authHeader(token: string) { return { authorization: `Bearer ${token}` } } async function createApp(): Promise { const { AppModule } = await import('../src/app.module') const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() const app = moduleRef.createNestApplication(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 { 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 { 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 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) })