Backend CI / type-check, build, tests (push) Successful in 1m0s
Co-authored-by: Cursor <cursoragent@cursor.com>
277 lines
9.8 KiB
TypeScript
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)
|
|
})
|