Files
gold_land_server/test/integration.test.ts
safavimohadeseh78andCursor 866be22395
Backend CI / type-check, build, tests (push) Successful in 1m16s
feat: complete the thirteen-stage financial learning path
Server grades levels 7-13 and keeps castle attempt results separate from stored progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 19:41:24 +03:30

2231 lines
85 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Phase B + Phase C integration tests — always uses a temporary SQLite file (never backend/data/app.db).
* Run: npm run test:integration
*/
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe, BadRequestException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import bcrypt from 'bcryptjs'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { runSchemaMigrations } from '../src/database/migration.service'
import { DatabaseService } from '../src/database/database.service'
const JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-nest-test-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3999'
process.env.JWT_SECRET = JWT_SECRET
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
}
function authHeader(token: string) {
return { authorization: `Bearer ${token}` }
}
function parseJson<T>(payload: string): T {
return JSON.parse(payload) as T
}
function assertMeResponseShape(body: Record<string, unknown>) {
assert.ok(body.user && typeof body.user === 'object')
assert.ok(body.wallet && typeof body.wallet === 'object')
assert.ok(body.progress && typeof body.progress === 'object')
assert.ok(Array.isArray(body.achievements))
assert.ok(Array.isArray(body.transactions))
assert.ok(body.market && typeof body.market === 'object')
const progress = body.progress as Record<string, unknown>
assert.ok(Array.isArray(progress.mapLevels))
assert.equal(typeof progress.totalStars, 'number')
assert.equal(typeof progress.maxStars, 'number')
}
function assertFastifyDefaultAchievementSeed(
achievements: Array<{ id: string; unlocked: boolean }>,
) {
assert.equal(achievements.length, 4)
assert.deepEqual(
achievements.map((a) => ({ id: a.id, unlocked: a.unlocked })),
[
{ id: 'first-gold', unlocked: true },
{ id: 'saver', unlocked: true },
{ id: 'market', unlocked: false },
{ id: 'inflation', unlocked: false },
],
)
}
function testApiExceptionFilterShape() {
const filter = new ApiExceptionFilter()
let statusCode = 0
let body: unknown
const host = {
switchToHttp: () => ({
getResponse: () => ({
status(code: number) {
statusCode = code
return {
send(payload: unknown) {
body = payload
},
}
},
}),
}),
}
filter.catch(new BadRequestException('فیلد نامعتبر'), host as never)
assert.equal(statusCode, 400)
assert.deepEqual(body, { error: 'فیلد نامعتبر' })
}
function runSchemaMigrationsTwice(dbService: DatabaseService) {
const db = dbService.getDb()
runSchemaMigrations(db)
runSchemaMigrations(db)
const check = dbService.verifyMigrationsApplied()
assert.equal(check.ok, true)
}
async function runTests() {
testApiExceptionFilterShape()
const app = await createApp()
const dbService = app.get(DatabaseService)
try {
// 1–2. auth_accounts migration + idempotent re-run
const firstMigration = dbService.verifyMigrationsApplied()
assert.equal(firstMigration.ok, true, `missing tables: ${firstMigration.missing.join(', ')}`)
assert.ok(firstMigration.tables.includes('auth_accounts'))
runSchemaMigrationsTwice(dbService)
const health = await app.inject({ method: 'GET', url: '/health' })
assert.equal(health.statusCode, 200)
// 13. request without token → 401
const unauthorized = await app.inject({ method: 'GET', url: '/api/me' })
assert.equal(unauthorized.statusCode, 401)
assert.deepEqual(parseJson(unauthorized.payload), { error: 'لطفاً وارد شوید' })
// 10. demo / 123456 on seed
const demoLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'demo', password: '123456' },
})
assert.equal(demoLogin.statusCode, 200)
const demoBody = parseJson<{
token: string
user: { id: string; username: string; name: string }
me: Record<string, unknown>
}>(demoLogin.payload)
assert.equal(demoBody.user.id, 'demo')
assert.equal(demoBody.user.username, 'demo')
assert.ok(demoBody.token)
assertMeResponseShape(demoBody.me)
// 11. JWT payload contains userId
const jwtService = app.get(JwtService)
const decoded = jwtService.decode(demoBody.token) as { userId?: string }
assert.equal(decoded.userId, 'demo')
// 12. token signed with legacy-compatible secret accepted by Nest
const legacyToken = jwtService.sign({ userId: 'demo' })
const meWithLegacyToken = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(legacyToken),
})
assert.equal(meWithLegacyToken.statusCode, 200)
assertMeResponseShape(parseJson(meWithLegacyToken.payload))
// 14. malformed token → 401
const badToken = await app.inject({
method: 'GET',
url: '/api/me',
headers: { authorization: 'Bearer not-a-valid-jwt' },
})
assert.equal(badToken.statusCode, 401)
assert.deepEqual(parseJson(badToken.payload), {
error: 'نشست نامعتبر است. دوباره وارد شوید',
})
// 3. register success
const username = `user_${randomUUID().slice(0, 8)}`
const registerRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username, password: 'secret12', name: 'کاربر تست' },
})
assert.equal(registerRes.statusCode, 200)
const registerBody = parseJson<{
token: string
user: { id: string; username: string; name: string }
me: Record<string, unknown>
}>(registerRes.payload)
assert.equal(registerBody.user.username, username.toLowerCase())
assert.equal(registerBody.user.name, 'کاربر تست')
assert.ok(registerBody.token)
assertMeResponseShape(registerBody.me)
assert.equal(JSON.stringify(registerRes.payload).includes('password_hash'), false)
assert.equal(JSON.stringify(registerRes.payload).includes('passwordHash'), false)
// 6. password hash not leaked in API
const hashRow = dbService
.getDb()
.prepare('SELECT password_hash FROM auth_accounts WHERE username = ?')
.get(username.toLowerCase()) as { password_hash: string }
assert.ok(bcrypt.compareSync('secret12', hashRow.password_hash))
// 4. duplicate username → 409
const duplicate = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username, password: 'secret12' },
})
assert.equal(duplicate.statusCode, 409)
assert.deepEqual(parseJson(duplicate.payload), { error: 'این نام کاربری قبلاً ثبت شده' })
// 5 + 22. unknown/extra fields rejected on register
const extraField = await app.inject({
method: 'POST',
url: '/api/auth/register',
headers: { 'content-type': 'application/json' },
payload: JSON.stringify({ username: 'extra_user', password: 'secret12', hacker: true }),
})
assert.equal(extraField.statusCode, 400)
// 7. login success
const loginRes = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username, password: 'secret12' },
})
assert.equal(loginRes.statusCode, 200)
const loginBody = parseJson<{ token: string; user: { username: string } }>(loginRes.payload)
assert.equal(loginBody.user.username, username.toLowerCase())
// 8–9. login failures
const badUser = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'nobody_here', password: 'secret12' },
})
assert.equal(badUser.statusCode, 401)
assert.deepEqual(parseJson(badUser.payload), { error: 'نام کاربری یا رمز عبور اشتباه است' })
const badPass = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username, password: 'wrongpass' },
})
assert.equal(badPass.statusCode, 401)
assert.deepEqual(parseJson(badPass.payload), { error: 'نام کاربری یا رمز عبور اشتباه است' })
// Legacy hash compatibility — pre-existing bcrypt hash verifies
const legacyUser = `legacy_${randomUUID().slice(0, 8)}`
const legacyUserId = randomUUID()
const legacyHash = bcrypt.hashSync('legacyPass1', 10)
const now = new Date().toISOString()
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
legacyUserId,
JSON.stringify({ id: legacyUserId, name: 'Legacy', level: 1, xp: 0, maxXp: 100, coins: 50, cash: 1000, goldGrams: 0, missionProgress: 0, missionTarget: 3, completedChallenges: [], completedLessons: [], completedGames: [], claimedRewards: [], ownedItems: [], achievements: [], mapLevels: [], totalStars: 0, maxStars: 39, marketSelectedRange: '7d', transactions: [] }),
now,
)
dbService.getDb().prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
).run(randomUUID(), legacyUser, legacyHash, legacyUserId, now)
const legacyLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: legacyUser, password: 'legacyPass1' },
})
assert.equal(legacyLogin.statusCode, 200)
const userToken = loginBody.token
const extraProfileField = await app.inject({
method: 'PATCH',
url: '/api/me/profile',
headers: { ...authHeader(userToken), 'content-type': 'application/json' },
payload: JSON.stringify({ name: 'نام', extra: true }),
})
assert.equal(extraProfileField.statusCode, 400)
// 15. GET /api/me shape
const meRes = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(userToken),
})
assert.equal(meRes.statusCode, 200)
const meBody = parseJson<Record<string, unknown>>(meRes.payload)
assertMeResponseShape(meBody)
// 16. GET /api/me for JWT user not yet in DB — ensureUser creates user
const freshUserId = randomUUID()
const freshToken = jwtService.sign({ userId: freshUserId })
const freshMe = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(freshToken),
})
assert.equal(freshMe.statusCode, 200)
const freshMeBody = parseJson<{ user: { id: string } }>(freshMe.payload)
assert.equal(freshMeBody.user.id, freshUserId)
// corrupted state_json → controlled error
dbService.getDb().prepare('UPDATE users SET state_json = ? WHERE id = ?').run('{bad json', freshUserId)
const corruptMe = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(freshToken),
})
assert.equal(corruptMe.statusCode, 401)
assert.deepEqual(parseJson(corruptMe.payload), {
error: 'نشست نامعتبر است. دوباره وارد شوید',
})
// 17–18. profile update persists without wiping other state fields
const beforeProfile = parseJson<{ user: { name: string }; wallet: { cash: number } }>(
(
await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(userToken),
})
).payload,
)
const originalCash = beforeProfile.wallet.cash
const profileRes = await app.inject({
method: 'PATCH',
url: '/api/me/profile',
headers: authHeader(userToken),
payload: { name: 'نام جدید' },
})
assert.equal(profileRes.statusCode, 200)
const profileBody = parseJson<{ user: { name: string }; wallet: { cash: number } }>(
profileRes.payload,
)
assert.equal(profileBody.user.name, 'نام جدید')
assert.equal(profileBody.wallet.cash, originalCash)
const profileAgain = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(userToken),
})
assert.equal(parseJson<{ user: { name: string } }>(profileAgain.payload).user.name, 'نام جدید')
const invalidProfile = await app.inject({
method: 'PATCH',
url: '/api/me/profile',
headers: authHeader(userToken),
payload: { name: ' ' },
})
assert.equal(invalidProfile.statusCode, 400)
assert.deepEqual(parseJson(invalidProfile.payload), { error: 'نام نمایشی الزامی است' })
// 19–21. password change flow
const passwordRes = await app.inject({
method: 'PATCH',
url: '/api/me/password',
headers: authHeader(userToken),
payload: { currentPassword: 'secret12', newPassword: 'newpass9' },
})
assert.equal(passwordRes.statusCode, 200)
assert.deepEqual(parseJson(passwordRes.payload), { ok: true })
const oldPassLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username, password: 'secret12' },
})
assert.equal(oldPassLogin.statusCode, 401)
const newPassLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username, password: 'newpass9' },
})
assert.equal(newPassLogin.statusCode, 200)
const wrongCurrent = await app.inject({
method: 'PATCH',
url: '/api/me/password',
headers: authHeader(parseJson<{ token: string }>(newPassLogin.payload).token),
payload: { currentPassword: 'wrong', newPassword: 'another1' },
})
assert.equal(wrongCurrent.statusCode, 401)
assert.deepEqual(parseJson(wrongCurrent.payload), { error: 'رمز عبور فعلی اشتباه است' })
const shortNew = await app.inject({
method: 'PATCH',
url: '/api/me/password',
headers: authHeader(parseJson<{ token: string }>(newPassLogin.payload).token),
payload: { currentPassword: 'newpass9', newPassword: '123' },
})
assert.equal(shortNew.statusCode, 400)
assert.deepEqual(parseJson(shortNew.payload), { error: 'رمز عبور جدید باید حداقل ۶ کاراکتر باشد' })
// password change for user without auth account → 404
const orphanId = randomUUID()
const orphanToken = jwtService.sign({ userId: orphanId })
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
orphanId,
JSON.stringify({ id: orphanId, name: 'Orphan', level: 1, xp: 0, maxXp: 100, coins: 0, cash: 0, goldGrams: 0, missionProgress: 0, missionTarget: 3, completedChallenges: [], completedLessons: [], completedGames: [], claimedRewards: [], ownedItems: [], achievements: [], mapLevels: [], totalStars: 0, maxStars: 39, marketSelectedRange: '7d', transactions: [] }),
now,
)
const orphanPass = await app.inject({
method: 'PATCH',
url: '/api/me/password',
headers: authHeader(orphanToken),
payload: { currentPassword: 'x', newPassword: 'yyyyyy' },
})
assert.equal(orphanPass.statusCode, 404)
assert.deepEqual(parseJson(orphanPass.payload), { error: 'حساب کاربری پیدا نشد' })
// --- Phase D: Gold Land integration ---
const { getGradableQuestions } = await import('../src/gold-land/lessons/lesson.grader')
const { inflationLesson } = await import('../src/gold-land/lessons/inflation.lesson')
const { GOLD_LAND_LEVELS } = await import('../src/gold-land/levels/levels')
const glToken = parseJson<{ token: string }>(newPassLogin.payload).token
const levelsMeta = await app.inject({
method: 'GET',
url: '/api/gold-land/levels',
headers: authHeader(glToken),
})
assert.equal(levelsMeta.statusCode, 200)
const levelsBody = parseJson<{ levels: Array<{ id: number; isActive: boolean }> }>(levelsMeta.payload)
assert.equal(levelsBody.levels.length, GOLD_LAND_LEVELS.length)
assert.equal(levelsBody.levels.filter((l) => l.isActive).length, 13)
const noAuthProgress = await app.inject({ method: 'GET', url: '/api/gold-land/progress' })
assert.equal(noAuthProgress.statusCode, 401)
const progressRes = await app.inject({
method: 'GET',
url: '/api/gold-land/progress',
headers: authHeader(glToken),
})
assert.equal(progressRes.statusCode, 200)
const progressBody = parseJson<{
levels: Array<{ id: number; status: string }>
totalStars: number
maxStars: number
}>(progressRes.payload)
assert.equal(progressBody.maxStars, 39)
assert.equal(progressBody.levels.find((l) => l.id === 1)?.status !== 'locked', true)
assert.equal(progressBody.levels.find((l) => l.id === 6)?.status, 'locked')
const lockedStart = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/start',
headers: authHeader(glToken),
})
assert.equal(lockedStart.statusCode, 403)
const glUserId = dbService
.getDb()
.prepare('SELECT user_id FROM auth_accounts WHERE username = ?')
.get(username.toLowerCase()) as { user_id: string }
for (let levelId = 1; levelId <= 5; levelId++) {
dbService.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, ?, ?, ?)
ON CONFLICT(user_id, level_id) DO UPDATE SET stars = 1, best_score = 65, completed_at = excluded.completed_at
`).run(glUserId.user_id, levelId, now, now, now)
}
const levelDetail = await app.inject({
method: 'GET',
url: '/api/gold-land/levels/6',
headers: authHeader(glToken),
})
assert.equal(levelDetail.statusCode, 200)
assert.ok(!levelDetail.payload.includes('correctAnswerId'))
assert.ok(!levelDetail.payload.includes('correctOrder'))
const startRes = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/start',
headers: authHeader(glToken),
})
assert.equal(startRes.statusCode, 200)
assert.deepEqual(parseJson(startRes.payload), { levelId: 6, status: 'in_progress' })
const inflationAnswers = getGradableQuestions(inflationLesson).map(({ questionId, step }) => {
if (step.type === 'sort') {
return { questionId, orderedItemIds: [...step.correctOrder] }
}
return { questionId, answerId: step.correctAnswerId }
})
const completeRes = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/complete',
headers: { ...authHeader(glToken), 'content-type': 'application/json' },
payload: JSON.stringify({ answers: inflationAnswers, durationMs: 5000 }),
})
assert.equal(completeRes.statusCode, 200)
const completeBody = parseJson<{
levelId: number
stars: number
score: number
reward: { xp: number; coins: number }
me: { progress: { mapLevels: Array<{ id: number; stars: number }> } }
}>(completeRes.payload)
assert.equal(completeBody.levelId, 6)
assert.equal(completeBody.stars, 3)
assert.equal(completeBody.score, 100)
assert.ok(completeBody.reward.xp > 0)
assert.equal(completeBody.me.progress.mapLevels.find((l) => l.id === 6)?.stars, 3)
const meAfter = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(glToken),
})
assert.equal(meAfter.statusCode, 200)
const glMeBody = parseJson<{ progress: { mapLevels: Array<{ id: number; stars: number }> } }>(
meAfter.payload,
)
assert.equal(glMeBody.progress.mapLevels.find((l) => l.id === 6)?.stars, 3)
const duplicateComplete = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/complete',
headers: { ...authHeader(glToken), 'content-type': 'application/json' },
payload: JSON.stringify({ answers: inflationAnswers }),
})
assert.equal(duplicateComplete.statusCode, 200)
assert.equal(parseJson<{ reward: { xp: number } }>(duplicateComplete.payload).reward.xp, 0)
const extraFieldComplete = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/complete',
headers: { ...authHeader(glToken), 'content-type': 'application/json' },
payload: JSON.stringify({ answers: inflationAnswers, score: 100 }),
})
assert.equal(extraFieldComplete.statusCode, 400)
// --- Phase E: Wallet + Market integration ---
const { GOLD_PRICE } = await import('../src/shared/catalog/market.constants')
const walletUser = `wallet_${randomUUID().slice(0, 8)}`
const walletUserId = randomUUID()
const walletHash = bcrypt.hashSync('walletPass1', 10)
const walletState = {
id: walletUserId,
name: 'Wallet Test',
level: 5,
xp: 100,
maxXp: 200,
coins: 10_000,
cash: 100_000,
goldGrams: 42.5,
missionProgress: 3,
missionTarget: 20,
completedChallenges: ['ch-1'],
completedLessons: ['lesson-1'],
completedGames: ['game-1'],
claimedRewards: ['reward-1'],
ownedItems: ['item-1'],
achievements: [
{ id: 'first-gold', title: 'اولین خرید طلا', description: 'd', icon: '🥇', unlocked: false },
],
mapLevels: [{ id: 1, title: 'تست', stars: 2, maxStars: 3, status: 'completed', x: 10, y: 20 }],
totalStars: 2,
maxStars: 39,
marketSelectedRange: '1d',
transactions: [
{ id: 'seed-1', type: 'expense', title: 'خرید طلا', amount: 1000, unit: 'تومان', date: '۱۴۰۴/۰۱/۰۱' },
],
}
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
walletUserId,
JSON.stringify(walletState),
now,
)
dbService.getDb().prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
).run(randomUUID(), walletUser, walletHash, walletUserId, now)
const walletLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: walletUser, password: 'walletPass1' },
})
assert.equal(walletLogin.statusCode, 200)
const walletToken = parseJson<{ token: string }>(walletLogin.payload).token
const walletEndpoints = [
{ method: 'GET' as const, url: '/api/wallet/transactions' },
{ method: 'POST' as const, url: '/api/wallet/gold/buy', payload: { grams: 0.01 } },
{ method: 'POST' as const, url: '/api/wallet/gold/sell', payload: { grams: 0.01 } },
{ method: 'GET' as const, url: '/api/market/gold?range=1d' },
{ method: 'PATCH' as const, url: '/api/market/preferences', payload: { range: '7d' } },
]
for (const ep of walletEndpoints) {
const unauth = await app.inject({
method: ep.method,
url: ep.url,
payload: ep.payload,
})
assert.equal(unauth.statusCode, 401, `expected 401 for ${ep.method} ${ep.url}`)
}
const txRes = await app.inject({
method: 'GET',
url: '/api/wallet/transactions',
headers: authHeader(walletToken),
})
assert.equal(txRes.statusCode, 200)
const txBody = parseJson<{ transactions: Array<{ id: string; type: string; title: string }> }>(
txRes.payload,
)
assert.ok(Array.isArray(txBody.transactions))
assert.equal(txBody.transactions.length, 1)
assert.equal(txBody.transactions[0].id, 'seed-1')
const marketRes = await app.inject({
method: 'GET',
url: '/api/market/gold?range=7d',
headers: authHeader(walletToken),
})
assert.equal(marketRes.statusCode, 200)
const marketBody = parseJson<{
currentPrice: number
dailyChangePercent: number
range: string
history: number[]
updatedAt: string
}>(marketRes.payload)
assert.equal(marketBody.currentPrice, GOLD_PRICE)
assert.equal(marketBody.dailyChangePercent, 2.4)
assert.equal(marketBody.range, '7d')
assert.ok(Array.isArray(marketBody.history))
assert.equal(marketBody.history.length, 28)
assert.equal(marketBody.history[marketBody.history.length - 1], GOLD_PRICE)
assert.ok(marketBody.updatedAt)
const buyRes = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(walletToken), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.05 }),
})
assert.equal(buyRes.statusCode, 200)
const buyBody = parseJson<{
wallet: { cash: number; goldGrams: number }
transactions: Array<{ type: string; title: string; amount: number; unit: string }>
progress: { completedChallenges: string[]; mapLevels: Array<{ id: number; stars: number }> }
achievements: Array<{ id: string; unlocked: boolean }>
}>(buyRes.payload)
assert.equal(buyBody.wallet.cash, 100_000 - 0.05 * GOLD_PRICE)
assert.equal(buyBody.wallet.goldGrams, 42.55)
assert.ok(buyBody.wallet.cash >= 0)
assert.ok(buyBody.wallet.goldGrams >= 0)
assert.equal(buyBody.transactions[0].type, 'expense')
assert.equal(buyBody.transactions[0].title, 'خرید طلا')
assert.equal(buyBody.transactions[0].amount, 0.05 * GOLD_PRICE)
assert.equal(buyBody.transactions[0].unit, 'تومان')
assert.deepEqual(buyBody.progress.completedChallenges, ['ch-1'])
assert.equal(buyBody.progress.mapLevels[0].stars, 2)
assert.equal(buyBody.achievements.find((a) => a.id === 'first-gold')?.unlocked, true)
const meAfterBuy = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(walletToken),
})
assert.equal(meAfterBuy.statusCode, 200)
const meBuyBody = parseJson<{ wallet: { cash: number; goldGrams: number } }>(meAfterBuy.payload)
assert.equal(meBuyBody.wallet.cash, buyBody.wallet.cash)
assert.equal(meBuyBody.wallet.goldGrams, buyBody.wallet.goldGrams)
const sellRes = await app.inject({
method: 'POST',
url: '/api/wallet/gold/sell',
headers: { ...authHeader(walletToken), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.001 }),
})
assert.equal(sellRes.statusCode, 200)
const sellBody = parseJson<{
wallet: { cash: number; goldGrams: number }
transactions: Array<{ type: string; title: string; amount: number }>
}>(sellRes.payload)
assert.equal(sellBody.wallet.goldGrams, 42.549)
assert.equal(sellBody.wallet.cash, buyBody.wallet.cash + 0.001 * GOLD_PRICE)
assert.equal(sellBody.transactions[0].type, 'income')
assert.equal(sellBody.transactions[0].title, 'فروش طلا')
assert.equal(sellBody.transactions[0].amount, 0.001 * GOLD_PRICE)
const meAfterSell = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(walletToken),
})
assert.equal(meAfterSell.statusCode, 200)
const meSellBody = parseJson<{ wallet: { cash: number; goldGrams: number } }>(meAfterSell.payload)
assert.equal(meSellBody.wallet.cash, sellBody.wallet.cash)
assert.equal(meSellBody.wallet.goldGrams, sellBody.wallet.goldGrams)
const zeroBuy = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(walletToken),
payload: { grams: 0 },
})
assert.equal(zeroBuy.statusCode, 400)
assert.deepEqual(parseJson(zeroBuy.payload), { error: 'مقدار نامعتبر است' })
const negativeBuy = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(walletToken),
payload: { grams: -1 },
})
assert.equal(negativeBuy.statusCode, 400)
assert.deepEqual(parseJson(negativeBuy.payload), { error: 'مقدار نامعتبر است' })
const nanBuy = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(walletToken),
payload: { grams: Number.NaN },
})
assert.equal(nanBuy.statusCode, 400)
const infinityBuy = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(walletToken),
payload: { grams: Number.POSITIVE_INFINITY },
})
assert.equal(infinityBuy.statusCode, 400)
const extraFieldBuy = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(walletToken), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.01, goldPrice: 1 }),
})
assert.equal(extraFieldBuy.statusCode, 400)
const stateBeforeFail = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(walletUserId) as {
state_json: string
}
).state_json
const insufficientCash = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(walletToken),
payload: { grams: 1000 },
})
assert.equal(insufficientCash.statusCode, 400)
assert.deepEqual(parseJson(insufficientCash.payload), { error: 'موجودی نقد کافی نیست' })
const stateAfterFailBuy = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(walletUserId) as {
state_json: string
}
).state_json
assert.equal(stateAfterFailBuy, stateBeforeFail, 'failed buy must rollback state')
const insufficientGold = await app.inject({
method: 'POST',
url: '/api/wallet/gold/sell',
headers: authHeader(walletToken),
payload: { grams: 9999 },
})
assert.equal(insufficientGold.statusCode, 400)
assert.deepEqual(parseJson(insufficientGold.payload), { error: 'طلای کافی نداری' })
const stateAfterFailSell = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(walletUserId) as {
state_json: string
}
).state_json
assert.equal(stateAfterFailSell, stateBeforeFail, 'failed sell must rollback state')
const prefsRes = await app.inject({
method: 'PATCH',
url: '/api/market/preferences',
headers: authHeader(walletToken),
payload: { range: '30d' },
})
assert.equal(prefsRes.statusCode, 200)
const prefsBody = parseJson<{ market: { selectedRange: string }; progress: { completedLessons: string[] } }>(
prefsRes.payload,
)
assert.equal(prefsBody.market.selectedRange, '30d')
assert.deepEqual(prefsBody.progress.completedLessons, ['lesson-1'])
const extraFieldPrefs = await app.inject({
method: 'PATCH',
url: '/api/market/preferences',
headers: { ...authHeader(walletToken), 'content-type': 'application/json' },
payload: JSON.stringify({ range: '7d', extra: true }),
})
assert.equal(extraFieldPrefs.statusCode, 400)
const invalidRangePrefs = await app.inject({
method: 'PATCH',
url: '/api/market/preferences',
headers: authHeader(walletToken),
payload: { range: 'invalid' },
})
assert.equal(invalidRangePrefs.statusCode, 400)
const invalidMarketRange = await app.inject({
method: 'GET',
url: '/api/market/gold?range=invalid',
headers: authHeader(walletToken),
})
assert.equal(invalidMarketRange.statusCode, 200)
const invalidRangeBody = parseJson<{ range: string; history: number[] }>(invalidMarketRange.payload)
assert.equal(invalidRangeBody.range, 'invalid')
assert.equal(invalidRangeBody.history.length, 24)
// precision: third decimal trade
const precisionBuy = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(walletToken),
payload: { grams: 0.005 },
})
assert.equal(precisionBuy.statusCode, 200)
const precisionBody = parseJson<{ wallet: { cash: number; goldGrams: number } }>(precisionBuy.payload)
assert.equal(precisionBody.wallet.goldGrams, sellBody.wallet.goldGrams + 0.005)
assert.equal(precisionBody.wallet.cash, sellBody.wallet.cash - 0.005 * GOLD_PRICE)
assert.ok(precisionBody.wallet.cash >= 0)
assert.ok(precisionBody.wallet.goldGrams >= 0)
// transaction order: newest first
const txOrder = parseJson<{ transactions: Array<{ title: string }> }>(precisionBuy.payload)
assert.equal(txOrder.transactions[0].title, 'خرید طلا')
// --- Phase F1: Store integration ---
const { STORE_PRODUCTS } = await import('../src/shared/catalog/store.constants')
const storeUser = `store_${randomUUID().slice(0, 8)}`
const storeUserId = randomUUID()
const storeHash = bcrypt.hashSync('storePass1', 10)
const storeState = {
id: storeUserId,
name: 'Store Test',
level: 5,
xp: 100,
maxXp: 200,
coins: 10_000,
cash: 50_000,
goldGrams: 10,
missionProgress: 7,
missionTarget: 20,
completedChallenges: ['ch-a'],
completedLessons: ['lesson-a'],
completedGames: ['game-a'],
claimedRewards: ['reward-a'],
ownedItems: [] as string[],
achievements: [
{ id: 'first-gold', title: 'اولین خرید طلا', description: 'd', icon: '🥇', unlocked: true },
{ id: 'market', title: 'شاگرد بازار', description: 'd', icon: '📊', unlocked: false },
],
mapLevels: [{ id: 1, title: 'تست', stars: 1, maxStars: 3, status: 'completed', x: 10, y: 20 }],
totalStars: 1,
maxStars: 39,
marketSelectedRange: '7d',
transactions: [
{ id: 'seed-tx', type: 'income', title: 'seed', amount: 1, unit: 'سکه', date: '۱۴۰۴/۰۱/۰۱' },
],
}
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
storeUserId,
JSON.stringify(storeState),
now,
)
dbService.getDb().prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
).run(randomUUID(), storeUser, storeHash, storeUserId, now)
const storeLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: storeUser, password: 'storePass1' },
})
assert.equal(storeLogin.statusCode, 200)
const storeToken = parseJson<{ token: string }>(storeLogin.payload).token
const storeUnauthGet = await app.inject({ method: 'GET', url: '/api/store/products' })
assert.equal(storeUnauthGet.statusCode, 401)
const storeUnauthPost = await app.inject({
method: 'POST',
url: '/api/store/purchases',
payload: { productId: 'suit' },
})
assert.equal(storeUnauthPost.statusCode, 401)
const productsRes = await app.inject({
method: 'GET',
url: '/api/store/products',
headers: authHeader(storeToken),
})
assert.equal(productsRes.statusCode, 200)
const productsBody = parseJson<
Array<{ id: string; title: string; price: number; category: string; emoji: string }>
>(productsRes.payload)
assert.equal(productsBody.length, 6)
assert.deepEqual(
productsBody.map((p) => p.id),
STORE_PRODUCTS.map((p) => p.id),
)
assert.equal(productsBody[0].title, 'قلعه طلایی')
assert.equal(productsBody[0].price, 5000)
assert.equal(productsBody[0].category, 'special')
assert.equal(productsBody[0].emoji, '🏰')
const stateBeforeAnyPurchase = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(storeUserId) as {
state_json: string
}
).state_json
const lowCoinsState = JSON.parse(stateBeforeAnyPurchase) as typeof storeState
lowCoinsState.coins = 100
const lowCoinsJson = JSON.stringify(lowCoinsState)
dbService.getDb().prepare('UPDATE users SET state_json = ? WHERE id = ?').run(lowCoinsJson, storeUserId)
const insufficientCoins = await app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: authHeader(storeToken),
payload: { productId: 'castle' },
})
assert.equal(insufficientCoins.statusCode, 400)
assert.deepEqual(parseJson(insufficientCoins.payload), { error: 'سکه کافی نداری!' })
const stateAfterInsufficient = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(storeUserId) as {
state_json: string
}
).state_json
assert.equal(stateAfterInsufficient, lowCoinsJson)
lowCoinsState.coins = 10_000
dbService.getDb().prepare('UPDATE users SET state_json = ? WHERE id = ?').run(
JSON.stringify(lowCoinsState),
storeUserId,
)
const purchaseRes = await app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: authHeader(storeToken),
payload: { productId: 'suit' },
})
assert.equal(purchaseRes.statusCode, 200)
const purchaseBody = parseJson<{
user: { coins: number; missionProgress: number; level: number; xp: number }
progress: { ownedItems: string[]; completedGames: string[] }
transactions: Array<{ type: string; title: string; amount: number; unit: string }>
achievements: Array<{ id: string; unlocked: boolean }>
}>(purchaseRes.payload)
assert.equal(purchaseBody.user.coins, 10_000 - 2500)
assert.deepEqual(purchaseBody.progress.ownedItems, ['suit'])
assert.equal(purchaseBody.transactions[0].type, 'expense')
assert.equal(purchaseBody.transactions[0].title, 'خرید لباس ماجراجو')
assert.equal(purchaseBody.transactions[0].amount, 2500)
assert.equal(purchaseBody.transactions[0].unit, 'سکه')
assert.equal(purchaseBody.user.missionProgress, 7)
assert.equal(purchaseBody.user.level, 5)
assert.equal(purchaseBody.user.xp, 100)
assert.equal(purchaseBody.achievements.find((a) => a.id === 'market')?.unlocked, false)
assert.deepEqual(purchaseBody.progress.completedGames, ['game-a'])
const meAfterPurchase = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(storeToken),
})
assert.equal(meAfterPurchase.statusCode, 200)
const mePurchaseBody = parseJson<{
user: { coins: number }
progress: { ownedItems: string[] }
}>(meAfterPurchase.payload)
assert.equal(mePurchaseBody.user.coins, 7500)
assert.deepEqual(mePurchaseBody.progress.ownedItems, ['suit'])
const stateBeforeDuplicate = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(storeUserId) as {
state_json: string
}
).state_json
const duplicatePurchase = await app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: authHeader(storeToken),
payload: { productId: 'suit' },
})
assert.equal(duplicatePurchase.statusCode, 409)
assert.deepEqual(parseJson(duplicatePurchase.payload), { error: 'قبلاً خریدی!' })
const stateAfterDuplicate = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(storeUserId) as {
state_json: string
}
).state_json
assert.equal(stateAfterDuplicate, stateBeforeDuplicate)
const invalidProduct = await app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: authHeader(storeToken),
payload: { productId: 'not-a-product' },
})
assert.equal(invalidProduct.statusCode, 404)
assert.deepEqual(parseJson(invalidProduct.payload), { error: 'محصول پیدا نشد' })
const emptyProductId = await app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: authHeader(storeToken),
payload: { productId: '' },
})
assert.equal(emptyProductId.statusCode, 400)
const emptyBody = await app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: { ...authHeader(storeToken), 'content-type': 'application/json' },
payload: JSON.stringify({}),
})
assert.equal(emptyBody.statusCode, 400)
const extraFieldPurchase = await app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: { ...authHeader(storeToken), 'content-type': 'application/json' },
payload: JSON.stringify({ productId: 'pet', price: 1 }),
})
assert.equal(extraFieldPurchase.statusCode, 400)
const clientPriceField = await app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: { ...authHeader(storeToken), 'content-type': 'application/json' },
payload: JSON.stringify({ productId: 'pet', coins: 99999 }),
})
assert.equal(clientPriceField.statusCode, 400)
const boostPurchase = await app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: authHeader(storeToken),
payload: { productId: 'boost-xp' },
})
assert.equal(boostPurchase.statusCode, 200)
const boostBody = parseJson<{
user: { coins: number; level: number; xp: number; maxXp: number }
progress: { ownedItems: string[] }
transactions: Array<{ title: string; amount: number }>
}>(boostPurchase.payload)
assert.equal(boostBody.user.coins, 7500 - 800)
assert.ok(boostBody.progress.ownedItems.includes('boost-xp'))
assert.equal(boostBody.user.level, 5)
assert.equal(boostBody.user.xp, 100)
assert.equal(boostBody.transactions[0].title, 'خرید تقویت XP')
assert.equal(boostBody.transactions[0].amount, 800)
// --- Phase F2: Games integration ---
const { GAMES } = await import('../src/shared/catalog/games.constants')
const gamesUser = `games_${randomUUID().slice(0, 8)}`
const gamesUserId = randomUUID()
const gamesHash = bcrypt.hashSync('gamesPass1', 10)
const gamesState = {
id: gamesUserId,
name: 'Games Test',
level: 5,
xp: 100,
maxXp: 200,
coins: 1000,
cash: 20_000,
goldGrams: 5,
missionProgress: 4,
missionTarget: 20,
completedChallenges: [],
completedLessons: [],
completedGames: [],
claimedRewards: [],
ownedItems: ['suit'],
achievements: [
{ id: 'first-gold', title: 't', description: 'd', icon: '🥇', unlocked: true },
],
mapLevels: [{ id: 2, title: 't', stars: 1, maxStars: 3, status: 'completed', x: 1, y: 2 }],
totalStars: 1,
maxStars: 39,
marketSelectedRange: '1d',
transactions: [],
}
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
gamesUserId,
JSON.stringify(gamesState),
now,
)
dbService.getDb().prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
).run(randomUUID(), gamesUser, gamesHash, gamesUserId, now)
const gamesLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: gamesUser, password: 'gamesPass1' },
})
assert.equal(gamesLogin.statusCode, 200)
const gamesToken = parseJson<{ token: string }>(gamesLogin.payload).token
const gamesUnauthGet = await app.inject({ method: 'GET', url: '/api/games' })
assert.equal(gamesUnauthGet.statusCode, 401)
const gamesUnauthPost = await app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
payload: {},
})
assert.equal(gamesUnauthPost.statusCode, 401)
const gamesListRes = await app.inject({
method: 'GET',
url: '/api/games',
headers: authHeader(gamesToken),
})
assert.equal(gamesListRes.statusCode, 200)
const gamesList = parseJson<
Array<{ id: string; route?: string; rewardCoins?: number; rewardXp?: number }>
>(gamesListRes.payload)
assert.equal(gamesList.length, 6)
assert.deepEqual(
gamesList.map((g) => g.id),
GAMES.map((g) => g.id),
)
assert.equal(gamesList[0].route, '/games/gold-guess')
assert.equal(gamesList[0].rewardCoins, 20)
assert.equal(gamesList[0].rewardXp, 50)
assert.equal(gamesList[1].route, '/lessons/inflation')
assert.equal(gamesList[1].rewardXp, 40)
assert.equal(gamesList[1].rewardCoins, undefined)
const completeGoldGuess = await app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: { ...authHeader(gamesToken), 'content-type': 'application/json' },
payload: JSON.stringify({}),
})
assert.equal(completeGoldGuess.statusCode, 200)
const goldGuessBody = parseJson<{
user: { coins: number; xp: number; level: number; missionProgress: number }
progress: { claimedRewards: string[]; completedGames: string[]; ownedItems: string[] }
transactions: Array<{ type: string; title: string; amount: number; unit: string }>
achievements: Array<{ id: string; unlocked: boolean }>
}>(completeGoldGuess.payload)
assert.equal(goldGuessBody.user.coins, 1020)
assert.equal(goldGuessBody.user.xp, 150)
assert.equal(goldGuessBody.user.level, 5)
assert.deepEqual(goldGuessBody.progress.claimedRewards, ['gold-guess'])
assert.deepEqual(goldGuessBody.progress.completedGames, ['gold-guess'])
assert.equal(goldGuessBody.transactions[0].type, 'income')
assert.equal(goldGuessBody.transactions[0].title, 'پاداش بازی')
assert.equal(goldGuessBody.transactions[0].amount, 20)
assert.equal(goldGuessBody.transactions[0].unit, 'سکه')
assert.equal(goldGuessBody.user.missionProgress, 4)
assert.deepEqual(goldGuessBody.progress.ownedItems, ['suit'])
assert.equal(goldGuessBody.achievements[0].unlocked, true)
const meAfterGame = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(gamesToken),
})
assert.equal(meAfterGame.statusCode, 200)
const meGameBody = parseJson<{
user: { coins: number; xp: number }
progress: { claimedRewards: string[] }
}>(meAfterGame.payload)
assert.equal(meGameBody.user.coins, 1020)
assert.equal(meGameBody.user.xp, 150)
assert.deepEqual(meGameBody.progress.claimedRewards, ['gold-guess'])
const stateBeforeGameDuplicate = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(gamesUserId) as {
state_json: string
}
).state_json
const duplicateGoldGuess = await app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: authHeader(gamesToken),
payload: {},
})
assert.equal(duplicateGoldGuess.statusCode, 200)
const duplicateBody = parseJson<{ user: { coins: number; xp: number } }>(duplicateGoldGuess.payload)
assert.equal(duplicateBody.user.coins, 1020)
assert.equal(duplicateBody.user.xp, 150)
const stateAfterDuplicateGame = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(gamesUserId) as {
state_json: string
}
).state_json
assert.equal(stateAfterDuplicateGame, stateBeforeGameDuplicate)
const completeInflation = await app.inject({
method: 'POST',
url: '/api/games/inflation/complete',
headers: authHeader(gamesToken),
payload: {},
})
assert.equal(completeInflation.statusCode, 200)
const inflationBody = parseJson<{
user: { coins: number; xp: number }
progress: { claimedRewards: string[]; completedGames: string[] }
transactions: Array<{ title: string }>
}>(completeInflation.payload)
assert.equal(inflationBody.user.coins, 1020)
assert.equal(inflationBody.user.xp, 190)
assert.ok(inflationBody.progress.claimedRewards.includes('inflation'))
assert.ok(inflationBody.progress.completedGames.includes('inflation'))
assert.equal(inflationBody.transactions.length, 1)
const completePortfolio = await app.inject({
method: 'POST',
url: '/api/games/portfolio/complete',
headers: authHeader(gamesToken),
payload: {},
})
assert.equal(completePortfolio.statusCode, 200)
const portfolioBody = parseJson<{
user: { coins: number; xp: number }
progress: { claimedRewards: string[]; completedGames: string[] }
transactions: unknown[]
}>(completePortfolio.payload)
assert.equal(portfolioBody.user.coins, 1020)
assert.equal(portfolioBody.user.xp, 190)
assert.ok(portfolioBody.progress.claimedRewards.includes('portfolio'))
assert.ok(portfolioBody.progress.completedGames.includes('portfolio'))
assert.equal(portfolioBody.transactions.length, 1)
const levelUpUserId = randomUUID()
const levelUpState = {
...gamesState,
id: levelUpUserId,
xp: 950,
maxXp: 100,
level: 1,
coins: 500,
claimedRewards: [],
completedGames: [],
transactions: [],
}
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
levelUpUserId,
JSON.stringify(levelUpState),
now,
)
const levelUpToken = jwtService.sign({ userId: levelUpUserId })
const levelUpComplete = await app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: authHeader(levelUpToken),
payload: {},
})
assert.equal(levelUpComplete.statusCode, 200)
const levelUpBody = parseJson<{ user: { xp: number; level: number; maxXp: number; coins: number } }>(
levelUpComplete.payload,
)
assert.equal(levelUpBody.user.coins, 520)
assert.equal(levelUpBody.user.level, 7)
assert.equal(levelUpBody.user.xp, 125)
assert.equal(levelUpBody.user.maxXp, 231)
const rollbackUserId = randomUUID()
const rollbackState = {
id: rollbackUserId,
name: 'Rollback Test',
level: 3,
xp: 40,
maxXp: 120,
coins: 777,
cash: 12_345,
goldGrams: 3.5,
missionProgress: 2,
missionTarget: 15,
completedChallenges: ['daily-challenge'],
completedLessons: ['lesson-x'],
completedGames: [],
claimedRewards: [],
ownedItems: ['castle'],
achievements: [
{ id: 'saver', title: 't', description: 'd', icon: '💰', unlocked: true },
],
mapLevels: [{ id: 3, title: 'map', stars: 2, maxStars: 3, status: 'current', x: 5, y: 6 }],
totalStars: 2,
maxStars: 39,
marketSelectedRange: '30d',
transactions: [
{ id: 'rb-1', type: 'income', title: 'seed tx', amount: 10, unit: 'سکه', date: '۱۴۰۴/۰۲/۰۱' },
],
}
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
rollbackUserId,
JSON.stringify(rollbackState),
now,
)
const rollbackToken = jwtService.sign({ userId: rollbackUserId })
const rollbackDb = dbService.getDb()
const stateBeforeRollback = (
rollbackDb.prepare('SELECT state_json FROM users WHERE id = ?').get(rollbackUserId) as {
state_json: string
}
).state_json
rollbackDb.exec(`
CREATE TRIGGER test_abort_games_users_update
BEFORE UPDATE ON users
WHEN NEW.id = '${rollbackUserId}'
BEGIN
SELECT RAISE(ABORT, 'integration test rollback');
END;
`)
try {
const rollbackComplete = await app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: { ...authHeader(rollbackToken), 'content-type': 'application/json' },
payload: JSON.stringify({}),
})
assert.equal(rollbackComplete.statusCode, 500)
assert.deepEqual(parseJson(rollbackComplete.payload), { error: 'خطای سرور' })
const stateAfterRollback = (
rollbackDb.prepare('SELECT state_json FROM users WHERE id = ?').get(rollbackUserId) as {
state_json: string
}
).state_json
assert.equal(stateAfterRollback, stateBeforeRollback)
const beforeParsed = JSON.parse(stateBeforeRollback) as typeof rollbackState
const afterParsed = JSON.parse(stateAfterRollback) as typeof rollbackState
assert.equal(afterParsed.coins, beforeParsed.coins)
assert.equal(afterParsed.xp, beforeParsed.xp)
assert.equal(afterParsed.level, beforeParsed.level)
assert.equal(afterParsed.maxXp, beforeParsed.maxXp)
assert.deepEqual(afterParsed.claimedRewards, beforeParsed.claimedRewards)
assert.deepEqual(afterParsed.completedGames, beforeParsed.completedGames)
assert.deepEqual(afterParsed.transactions, beforeParsed.transactions)
assert.equal(afterParsed.cash, beforeParsed.cash)
assert.equal(afterParsed.goldGrams, beforeParsed.goldGrams)
assert.deepEqual(afterParsed.ownedItems, beforeParsed.ownedItems)
assert.deepEqual(afterParsed.achievements, beforeParsed.achievements)
assert.equal(afterParsed.missionProgress, beforeParsed.missionProgress)
assert.deepEqual(afterParsed.mapLevels, beforeParsed.mapLevels)
} finally {
rollbackDb.exec('DROP TRIGGER IF EXISTS test_abort_games_users_update')
}
const stateBeforeInvalid = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(gamesUserId) as {
state_json: string
}
).state_json
const invalidGame = await app.inject({
method: 'POST',
url: '/api/games/not-a-game/complete',
headers: authHeader(gamesToken),
payload: {},
})
assert.equal(invalidGame.statusCode, 404)
assert.deepEqual(parseJson(invalidGame.payload), { error: 'بازی پیدا نشد' })
const stateAfterInvalid = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(gamesUserId) as {
state_json: string
}
).state_json
assert.equal(stateAfterInvalid, stateBeforeInvalid)
const extraFieldGame = await app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: { ...authHeader(gamesToken), 'content-type': 'application/json' },
payload: JSON.stringify({ score: 100 }),
})
assert.equal(extraFieldGame.statusCode, 400)
const rewardFieldGame = await app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: { ...authHeader(gamesToken), 'content-type': 'application/json' },
payload: JSON.stringify({ reward: 999 }),
})
assert.equal(rewardFieldGame.statusCode, 400)
// --- Phase F3: Daily Challenge integration ---
const { DAILY_CHALLENGE, CHALLENGE_OPTIONS } = await import(
'../src/shared/catalog/challenge.constants'
)
const challengeUser = `challenge_${randomUUID().slice(0, 8)}`
const challengeUserId = randomUUID()
const challengeHash = bcrypt.hashSync('challengePass1', 10)
const challengeState = {
id: challengeUserId,
name: 'Challenge Test',
level: 4,
xp: 80,
maxXp: 100,
coins: 200,
cash: 5000,
goldGrams: 2,
missionProgress: 5,
missionTarget: 20,
completedChallenges: [] as string[],
completedLessons: [],
completedGames: ['game-a'],
claimedRewards: ['reward-a'],
ownedItems: ['pet'],
achievements: [{ id: 'saver', title: 't', description: 'd', icon: '💰', unlocked: true }],
mapLevels: [{ id: 1, title: 't', stars: 1, maxStars: 3, status: 'completed', x: 1, y: 2 }],
totalStars: 1,
maxStars: 39,
marketSelectedRange: '7d',
transactions: [],
}
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
challengeUserId,
JSON.stringify(challengeState),
now,
)
dbService.getDb().prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
).run(randomUUID(), challengeUser, challengeHash, challengeUserId, now)
const challengeLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: challengeUser, password: 'challengePass1' },
})
assert.equal(challengeLogin.statusCode, 200)
const challengeToken = parseJson<{ token: string }>(challengeLogin.payload).token
const challengeUnauthGet = await app.inject({ method: 'GET', url: '/api/challenges/daily' })
assert.equal(challengeUnauthGet.statusCode, 401)
const challengeUnauthPost = await app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
payload: { optionId: 'gold' },
})
assert.equal(challengeUnauthPost.statusCode, 401)
const dailyGet = await app.inject({
method: 'GET',
url: '/api/challenges/daily',
headers: authHeader(challengeToken),
})
assert.equal(dailyGet.statusCode, 200)
const dailyBody = parseJson<{
id: string
question: string
coinReward: number
xpReward: number
completed: boolean
options: Array<{ id: string; label: string; icon: string; color: string }>
}>(dailyGet.payload)
assert.equal(dailyBody.id, DAILY_CHALLENGE.id)
assert.equal(dailyBody.question, DAILY_CHALLENGE.question)
assert.equal(dailyBody.coinReward, 50)
assert.equal(dailyBody.xpReward, 30)
assert.equal(dailyBody.completed, false)
assert.deepEqual(
dailyBody.options.map((o) => o.id),
CHALLENGE_OPTIONS.map((o) => o.id),
)
assert.ok(!dailyGet.payload.includes('isCorrect'))
assert.ok(!dailyGet.payload.includes('feedback'))
assert.equal(dailyBody.options[0].label, 'طلا')
const completeGold = await app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: authHeader(challengeToken),
payload: { optionId: 'gold' },
})
assert.equal(completeGold.statusCode, 200)
const goldBody = parseJson<{
user: { coins: number; xp: number; level: number; missionProgress: number }
progress: { completedChallenges: string[]; claimedRewards: string[]; completedGames: string[]; ownedItems: string[] }
transactions: Array<{ type: string; title: string; amount: number; unit: string }>
achievements: Array<{ id: string; unlocked: boolean }>
feedback: string
alreadyCompleted: boolean
}>(completeGold.payload)
assert.equal(goldBody.user.coins, 250)
assert.equal(goldBody.user.xp, 10)
assert.equal(goldBody.user.level, 5)
assert.equal(goldBody.user.missionProgress, 6)
assert.deepEqual(goldBody.progress.completedChallenges, ['daily-challenge'])
assert.equal(goldBody.transactions[0].type, 'income')
assert.equal(goldBody.transactions[0].title, 'چالش روزانه')
assert.equal(goldBody.transactions[0].amount, 50)
assert.equal(goldBody.transactions[0].unit, 'سکه')
assert.equal(goldBody.feedback, CHALLENGE_OPTIONS[0].feedback)
assert.equal(goldBody.alreadyCompleted, false)
assert.deepEqual(goldBody.progress.claimedRewards, ['reward-a'])
assert.deepEqual(goldBody.progress.completedGames, ['game-a'])
assert.deepEqual(goldBody.progress.ownedItems, ['pet'])
assert.equal(goldBody.achievements[0].unlocked, true)
const dailyAfter = await app.inject({
method: 'GET',
url: '/api/challenges/daily',
headers: authHeader(challengeToken),
})
assert.equal(parseJson<{ completed: boolean }>(dailyAfter.payload).completed, true)
const meAfterChallenge = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(challengeToken),
})
assert.equal(meAfterChallenge.statusCode, 200)
const meChallengeBody = parseJson<{
user: { coins: number; xp: number }
progress: { completedChallenges: string[] }
}>(meAfterChallenge.payload)
assert.equal(meChallengeBody.user.coins, 250)
assert.equal(meChallengeBody.user.xp, 10)
assert.deepEqual(meChallengeBody.progress.completedChallenges, ['daily-challenge'])
const stateBeforeChallengeDuplicate = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(challengeUserId) as {
state_json: string
}
).state_json
const duplicateChallenge = await app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: authHeader(challengeToken),
payload: { optionId: 'gold' },
})
assert.equal(duplicateChallenge.statusCode, 200)
const duplicateChallengeBody = parseJson<{
user: { coins: number; xp: number; missionProgress: number }
feedback: string
alreadyCompleted: boolean
}>(duplicateChallenge.payload)
assert.equal(duplicateChallengeBody.alreadyCompleted, true)
assert.equal(duplicateChallengeBody.user.coins, 250)
assert.equal(duplicateChallengeBody.user.xp, 10)
assert.equal(duplicateChallengeBody.user.missionProgress, 6)
assert.equal(duplicateChallengeBody.feedback, CHALLENGE_OPTIONS[0].feedback)
const stateAfterChallengeDuplicate = (
dbService.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(challengeUserId) as {
state_json: string
}
).state_json
assert.equal(stateAfterChallengeDuplicate, stateBeforeChallengeDuplicate)
const wrongUserId = randomUUID()
const wrongState = {
...challengeState,
id: wrongUserId,
completedChallenges: [],
coins: 300,
xp: 70,
maxXp: 100,
level: 4,
missionProgress: 2,
transactions: [],
}
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
wrongUserId,
JSON.stringify(wrongState),
now,
)
const wrongToken = jwtService.sign({ userId: wrongUserId })
const completeWrong = await app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: authHeader(wrongToken),
payload: { optionId: 'phone' },
})
assert.equal(completeWrong.statusCode, 200)
const wrongBody = parseJson<{
user: { coins: number; xp: number; missionProgress: number }
progress: { completedChallenges: string[] }
feedback: string
alreadyCompleted: boolean
}>(completeWrong.payload)
assert.equal(wrongBody.user.coins, 350)
assert.equal(wrongBody.user.xp, 0)
assert.equal(wrongBody.user.level, 5)
assert.equal(wrongBody.user.missionProgress, 3)
assert.deepEqual(wrongBody.progress.completedChallenges, ['daily-challenge'])
assert.equal(wrongBody.feedback, CHALLENGE_OPTIONS[3].feedback)
assert.equal(wrongBody.alreadyCompleted, false)
const invalidOption = await app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: authHeader(wrongToken),
payload: { optionId: 'invalid' },
})
assert.equal(invalidOption.statusCode, 400)
assert.deepEqual(parseJson(invalidOption.payload), { error: 'گزینه نامعتبر است' })
const emptyOption = await app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: authHeader(wrongToken),
payload: { optionId: '' },
})
assert.equal(emptyOption.statusCode, 400)
const emptyBodyChallenge = await app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: { ...authHeader(wrongToken), 'content-type': 'application/json' },
payload: JSON.stringify({}),
})
assert.equal(emptyBodyChallenge.statusCode, 400)
const extraFieldChallenge = await app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: { ...authHeader(challengeToken), 'content-type': 'application/json' },
payload: JSON.stringify({ optionId: 'gold', coins: 999 }),
})
assert.equal(extraFieldChallenge.statusCode, 400)
const rollbackChallengeUserId = randomUUID()
const rollbackChallengeState = {
...challengeState,
id: rollbackChallengeUserId,
completedChallenges: [],
coins: 400,
xp: 90,
maxXp: 100,
level: 4,
missionProgress: 1,
transactions: [],
}
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
rollbackChallengeUserId,
JSON.stringify(rollbackChallengeState),
now,
)
const rollbackChallengeToken = jwtService.sign({ userId: rollbackChallengeUserId })
const rollbackChallengeDb = dbService.getDb()
const stateBeforeChallengeRollback = (
rollbackChallengeDb.prepare('SELECT state_json FROM users WHERE id = ?').get(rollbackChallengeUserId) as {
state_json: string
}
).state_json
rollbackChallengeDb.exec(`
CREATE TRIGGER test_abort_challenge_users_update
BEFORE UPDATE ON users
WHEN NEW.id = '${rollbackChallengeUserId}'
BEGIN
SELECT RAISE(ABORT, 'integration test challenge rollback');
END;
`)
try {
const rollbackChallengeComplete = await app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: authHeader(rollbackChallengeToken),
payload: { optionId: 'gold' },
})
assert.equal(rollbackChallengeComplete.statusCode, 500)
assert.deepEqual(parseJson(rollbackChallengeComplete.payload), { error: 'خطای سرور' })
const stateAfterChallengeRollback = (
rollbackChallengeDb.prepare('SELECT state_json FROM users WHERE id = ?').get(rollbackChallengeUserId) as {
state_json: string
}
).state_json
assert.equal(stateAfterChallengeRollback, stateBeforeChallengeRollback)
const beforeRb = JSON.parse(stateBeforeChallengeRollback) as typeof rollbackChallengeState
const afterRb = JSON.parse(stateAfterChallengeRollback) as typeof rollbackChallengeState
assert.equal(afterRb.coins, beforeRb.coins)
assert.equal(afterRb.xp, beforeRb.xp)
assert.equal(afterRb.level, beforeRb.level)
assert.equal(afterRb.maxXp, beforeRb.maxXp)
assert.equal(afterRb.missionProgress, beforeRb.missionProgress)
assert.deepEqual(afterRb.completedChallenges, beforeRb.completedChallenges)
assert.deepEqual(afterRb.transactions, beforeRb.transactions)
} finally {
rollbackChallengeDb.exec('DROP TRIGGER IF EXISTS test_abort_challenge_users_update')
}
// --- Phase F4: Achievements integration ---
const { ACHIEVEMENTS: achievementCatalog } = await import(
'../src/shared/catalog/achievements.constants'
)
const expectedDefinitionOrder = ['first-gold', 'saver', 'market', 'inflation']
const f4User = `f4_${randomUUID().slice(0, 8)}`
const f4UserId = randomUUID()
const f4Hash = bcrypt.hashSync('f4Pass123', 10)
const f4State = {
id: f4UserId,
name: 'F4 Test',
level: 5,
xp: 100,
maxXp: 200,
coins: 10_000,
cash: 10_000_000,
goldGrams: 0,
missionProgress: 3,
missionTarget: 20,
completedChallenges: [],
completedLessons: [],
completedGames: [],
claimedRewards: [],
ownedItems: [],
achievements: [
{ id: 'first-gold', title: 'اولین خرید طلا', description: 'd1', icon: '🥇', unlocked: false },
{ id: 'saver', title: 'پس‌اندازگر کوچک', description: 'd2', icon: '💰', unlocked: true },
{ id: 'market', title: 'شاگرد بازار', description: 'd3', icon: '📊', unlocked: false },
{ id: 'inflation', title: 'شکست‌دهنده تورم', description: 'd4', icon: '👾', unlocked: false },
],
mapLevels: [{ id: 1, title: 'تست', stars: 1, maxStars: 3, status: 'current', x: 10, y: 20 }],
totalStars: 1,
maxStars: 39,
marketSelectedRange: '1d',
transactions: [],
}
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
f4UserId,
JSON.stringify(f4State),
now,
)
dbService.getDb().prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
).run(randomUUID(), f4User, f4Hash, f4UserId, now)
const f4Login = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: f4User, password: 'f4Pass123' },
})
assert.equal(f4Login.statusCode, 200)
const f4Token = parseJson<{ token: string }>(f4Login.payload).token
const achUnauth = await app.inject({ method: 'GET', url: '/api/achievements' })
assert.equal(achUnauth.statusCode, 401)
assert.deepEqual(parseJson(achUnauth.payload), { error: 'لطفاً وارد شوید' })
const achBadToken = await app.inject({
method: 'GET',
url: '/api/achievements',
headers: { authorization: 'Bearer not-a-valid-jwt' },
})
assert.equal(achBadToken.statusCode, 401)
assert.deepEqual(parseJson(achBadToken.payload), {
error: 'نشست نامعتبر است. دوباره وارد شوید',
})
const rowBeforeAch = dbService
.getDb()
.prepare('SELECT state_json, updated_at FROM users WHERE id = ?')
.get(f4UserId) as { state_json: string; updated_at: string }
const achRes = await app.inject({
method: 'GET',
url: '/api/achievements',
headers: authHeader(f4Token),
})
assert.equal(achRes.statusCode, 200)
const achBody = parseJson<{
definitions: Array<{ id: string; title: string; description: string; icon: string; unlocked?: boolean }>
unlocked: Array<{ id: string; title: string; description: string; icon: string; unlocked: boolean }>
}>(achRes.payload)
assert.ok(Array.isArray(achBody.definitions))
assert.ok(Array.isArray(achBody.unlocked))
assert.equal(achBody.definitions.length, achievementCatalog.length)
assert.deepEqual(
achBody.definitions.map((d) => d.id),
expectedDefinitionOrder,
)
for (const def of achBody.definitions) {
assert.equal(typeof def.id, 'string')
assert.equal(typeof def.title, 'string')
assert.equal(typeof def.description, 'string')
assert.equal(typeof def.icon, 'string')
assert.equal('unlocked' in def, false)
}
assert.deepEqual(
achBody.definitions.map(({ id, title, description, icon }) => ({ id, title, description, icon })),
achievementCatalog.map(({ id, title, description, icon }) => ({ id, title, description, icon })),
)
assert.deepEqual(achBody.unlocked, f4State.achievements)
assert.equal(achBody.unlocked.filter((a) => !a.unlocked).length, 3)
assert.equal(achBody.unlocked.find((a) => a.id === 'saver')?.unlocked, true)
const rowAfterAch = dbService
.getDb()
.prepare('SELECT state_json, updated_at FROM users WHERE id = ?')
.get(f4UserId) as { state_json: string; updated_at: string }
assert.equal(rowAfterAch.state_json, rowBeforeAch.state_json)
assert.equal(rowAfterAch.updated_at, rowBeforeAch.updated_at)
const catalogSnapshot = achievementCatalog.map((a) => ({ ...a }))
achBody.definitions[0].title = 'mutated'
assert.deepEqual(
achievementCatalog.map(({ id, title, description, icon }) => ({ id, title, description, icon })),
catalogSnapshot,
)
const { createDefaultUserState } = await import(
'../src/shared/catalog/default-user-state'
)
const defaultState1 = createDefaultUserState('f4-default-1')
const defaultState2 = createDefaultUserState('f4-default-2')
assert.notEqual(defaultState1.achievements, defaultState2.achievements)
assert.equal(defaultState1.achievements.length, achievementCatalog.length)
assert.deepEqual(
defaultState1.achievements.map((a) => a.id),
expectedDefinitionOrder,
)
for (const achievement of defaultState1.achievements) {
const expected =
achievement.id === 'first-gold' || achievement.id === 'saver'
? true
: false
assert.equal(achievement.unlocked, expected)
}
defaultState1.achievements[0].unlocked = false
assert.equal(defaultState2.achievements[0].unlocked, true)
const buyBeforeAch = await app.inject({
method: 'GET',
url: '/api/achievements',
headers: authHeader(f4Token),
})
const buyBeforeBody = parseJson<{ unlocked: Array<{ id: string; unlocked: boolean }> }>(
buyBeforeAch.payload,
)
assert.equal(buyBeforeBody.unlocked.find((a) => a.id === 'first-gold')?.unlocked, false)
const f4BuyRes = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(f4Token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.01 }),
})
assert.equal(f4BuyRes.statusCode, 200)
const f4BuyBody = parseJson<{ achievements: Array<{ id: string; unlocked: boolean }> }>(
f4BuyRes.payload,
)
assert.equal(f4BuyBody.achievements.find((a) => a.id === 'first-gold')?.unlocked, true)
const buyAfterAch = await app.inject({
method: 'GET',
url: '/api/achievements',
headers: authHeader(f4Token),
})
const buyAfterBody = parseJson<{ unlocked: Array<{ id: string; unlocked: boolean }> }>(
buyAfterAch.payload,
)
assert.equal(buyAfterBody.unlocked.find((a) => a.id === 'first-gold')?.unlocked, true)
const f4MeRes = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(f4Token),
})
const f4MeBody = parseJson<{ achievements: Array<{ id: string; unlocked: boolean }> }>(
f4MeRes.payload,
)
assert.equal(f4MeBody.achievements.find((a) => a.id === 'first-gold')?.unlocked, true)
const { unlockAchievement: unlockAchievementOnce } = await import(
'../src/shared/economy/user-economy.helpers'
)
const dupState = createDefaultUserState('f4-dup')
const dupCountBefore = dupState.achievements.length
unlockAchievementOnce(dupState, 'first-gold')
unlockAchievementOnce(dupState, 'first-gold')
assert.equal(dupState.achievements.length, dupCountBefore)
assert.equal(dupState.achievements.filter((a) => a.id === 'first-gold').length, 1)
const f4StoreRes = await app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: { ...authHeader(f4Token), 'content-type': 'application/json' },
payload: JSON.stringify({ productId: 'boost-xp' }),
})
assert.equal(f4StoreRes.statusCode, 200)
const f4StoreBody = parseJson<{ achievements: Array<{ id: string; unlocked: boolean }> }>(
f4StoreRes.payload,
)
assert.equal(f4StoreBody.achievements.length, f4State.achievements.length)
assert.equal(f4StoreBody.achievements.find((a) => a.id === 'market')?.unlocked, false)
const f4GameRes = await app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: { ...authHeader(f4Token), 'content-type': 'application/json' },
payload: JSON.stringify({}),
})
assert.equal(f4GameRes.statusCode, 200)
const f4GameBody = parseJson<{ achievements: Array<{ id: string; unlocked: boolean }> }>(
f4GameRes.payload,
)
assert.equal(f4GameBody.achievements.find((a) => a.id === 'market')?.unlocked, false)
const f4ChallengeRes = await app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: { ...authHeader(f4Token), 'content-type': 'application/json' },
payload: JSON.stringify({ optionId: 'gold' }),
})
assert.equal(f4ChallengeRes.statusCode, 200)
const f4ChallengeBody = parseJson<{ achievements: Array<{ id: string; unlocked: boolean }> }>(
f4ChallengeRes.payload,
)
assert.equal(f4ChallengeBody.achievements.find((a) => a.id === 'saver')?.unlocked, true)
assert.equal(f4ChallengeBody.achievements.find((a) => a.id === 'market')?.unlocked, false)
const f4FinalRow = dbService
.getDb()
.prepare('SELECT state_json FROM users WHERE id = ?')
.get(f4UserId) as { state_json: string }
const f4FinalState = JSON.parse(f4FinalRow.state_json) as typeof f4State
assert.ok(typeof f4FinalState.coins === 'number')
assert.ok(typeof f4FinalState.xp === 'number')
assert.ok(typeof f4FinalState.level === 'number')
assert.ok(typeof f4FinalState.maxXp === 'number')
assert.ok(Array.isArray(f4FinalState.ownedItems))
assert.ok(Array.isArray(f4FinalState.completedGames))
assert.ok(Array.isArray(f4FinalState.completedChallenges))
assert.ok(Array.isArray(f4FinalState.transactions))
assert.ok(Array.isArray(f4FinalState.mapLevels))
assert.equal(f4FinalState.achievements.find((a) => a.id === 'market')?.unlocked, false)
const nestRoutes = [
'GET /health',
'POST /api/auth/register',
'POST /api/auth/login',
'GET /api/me',
'PATCH /api/me/profile',
'PATCH /api/me/password',
'GET /api/wallet/transactions',
'POST /api/wallet/gold/buy',
'POST /api/wallet/gold/sell',
'GET /api/market/gold',
'PATCH /api/market/preferences',
'GET /api/gold-land/levels',
'GET /api/gold-land/progress',
'GET /api/gold-land/levels/:levelId',
'POST /api/gold-land/levels/:levelId/start',
'POST /api/gold-land/levels/:levelId/complete',
'GET /api/store/products',
'POST /api/store/purchases',
'GET /api/games',
'POST /api/games/:gameId/complete',
'GET /api/challenges/daily',
'POST /api/challenges/daily/complete',
'GET /api/achievements',
]
assert.equal(nestRoutes.length, 23)
assert.ok(nestRoutes.includes('GET /api/achievements'))
assert.equal(
nestRoutes.filter((r) => r.startsWith('GET /api/achievements') || r.startsWith('POST /api/achievements')).length,
1,
)
// --- Phase F4.1: Default achievement seed parity ---
const { DEFAULT_ACHIEVEMENT_UNLOCK_STATE, createDefaultAchievements } = await import(
'../src/shared/catalog/achievements.constants'
)
assert.deepEqual(DEFAULT_ACHIEVEMENT_UNLOCK_STATE, {
'first-gold': true,
saver: true,
market: false,
inflation: false,
})
const f41RegisterUser = `f41_reg_${randomUUID().slice(0, 8)}`
const f41RegisterRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username: f41RegisterUser, password: 'secret12', name: 'F41 Register' },
})
assert.equal(f41RegisterRes.statusCode, 200)
const f41RegisterBody = parseJson<{
token: string
me: { achievements: Array<{ id: string; unlocked: boolean }> }
}>(f41RegisterRes.payload)
assertFastifyDefaultAchievementSeed(f41RegisterBody.me.achievements)
const f41RegisterAch = await app.inject({
method: 'GET',
url: '/api/achievements',
headers: authHeader(f41RegisterBody.token),
})
assert.equal(f41RegisterAch.statusCode, 200)
const f41RegisterAchBody = parseJson<{
definitions: Array<{ id: string; unlocked?: boolean }>
unlocked: Array<{ id: string; unlocked: boolean }>
}>(f41RegisterAch.payload)
assertFastifyDefaultAchievementSeed(f41RegisterAchBody.unlocked)
for (const def of f41RegisterAchBody.definitions) {
assert.equal('unlocked' in def, false)
}
const f41DemoAchRes = await app.inject({
method: 'GET',
url: '/api/achievements',
headers: authHeader(demoBody.token),
})
assert.equal(f41DemoAchRes.statusCode, 200)
const f41DemoAchBody = parseJson<{
unlocked: Array<{ id: string; unlocked: boolean }>
}>(f41DemoAchRes.payload)
assertFastifyDefaultAchievementSeed(f41DemoAchBody.unlocked)
const f41DemoMeRes = await app.inject({
method: 'GET',
url: '/api/me',
headers: authHeader(demoBody.token),
})
const f41DemoMeBody = parseJson<{
achievements: Array<{ id: string; unlocked: boolean }>
}>(f41DemoMeRes.payload)
assertFastifyDefaultAchievementSeed(f41DemoMeBody.achievements)
const f41EnsureAuth = `f41_ensure_${randomUUID().slice(0, 8)}`
const f41EnsureUserId = randomUUID()
const f41EnsureHash = bcrypt.hashSync('ensurePass1', 10)
dbService.getDb().prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
).run(randomUUID(), f41EnsureAuth, f41EnsureHash, f41EnsureUserId, now)
const f41EnsureLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: f41EnsureAuth, password: 'ensurePass1' },
})
assert.equal(f41EnsureLogin.statusCode, 200)
const f41EnsureBody = parseJson<{
me: { achievements: Array<{ id: string; unlocked: boolean }> }
}>(f41EnsureLogin.payload)
assertFastifyDefaultAchievementSeed(f41EnsureBody.me.achievements)
const f41DefaultA = createDefaultAchievements()
const f41DefaultB = createDefaultAchievements()
assert.notEqual(f41DefaultA, f41DefaultB)
assert.notEqual(f41DefaultA[0], f41DefaultB[0])
f41DefaultA[0].unlocked = false
assert.equal(f41DefaultB[0].unlocked, true)
const f41LegacyUserId = randomUUID()
const f41LegacyState = {
id: f41LegacyUserId,
name: 'Legacy Seed',
level: 1,
xp: 0,
maxXp: 100,
coins: 50,
cash: 1000,
goldGrams: 0,
missionProgress: 0,
missionTarget: 3,
completedChallenges: [],
completedLessons: [],
completedGames: [],
claimedRewards: [],
ownedItems: [],
achievements: [{ id: 'custom-only', title: 'Custom', description: 'x', icon: '⭐', unlocked: true }],
mapLevels: [],
totalStars: 0,
maxStars: 39,
marketSelectedRange: '7d',
transactions: [],
}
const f41LegacyAuth = `f41_legacy_${randomUUID().slice(0, 8)}`
const f41LegacyJson = JSON.stringify(f41LegacyState)
const f41LegacyUpdatedAt = '2020-01-01T00:00:00.000Z'
dbService.getDb().prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
f41LegacyUserId,
f41LegacyJson,
f41LegacyUpdatedAt,
)
dbService.getDb().prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
).run(randomUUID(), f41LegacyAuth, bcrypt.hashSync('legacyPass1', 10), f41LegacyUserId, now)
const f41LegacyLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: f41LegacyAuth, password: 'legacyPass1' },
})
const f41LegacyToken = parseJson<{ token: string }>(f41LegacyLogin.payload).token
await app.inject({
method: 'GET',
url: '/api/achievements',
headers: authHeader(f41LegacyToken),
})
const f41LegacyRow = dbService
.getDb()
.prepare('SELECT state_json, updated_at FROM users WHERE id = ?')
.get(f41LegacyUserId) as { state_json: string; updated_at: string }
assert.equal(f41LegacyRow.state_json, f41LegacyJson)
assert.equal(f41LegacyRow.updated_at, f41LegacyUpdatedAt)
const f41DefaultBuyUser = `f41_buy_${randomUUID().slice(0, 8)}`
const f41DefaultBuyRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username: f41DefaultBuyUser, password: 'secret12', name: 'F41 Buy' },
})
const f41DefaultBuyBody = parseJson<{
token: string
me: { achievements: Array<{ id: string; unlocked: boolean }> }
}>(f41DefaultBuyRes.payload)
assert.equal(f41DefaultBuyBody.me.achievements.find((a) => a.id === 'first-gold')?.unlocked, true)
const f41DefaultBuyAchCount = f41DefaultBuyBody.me.achievements.length
const f41GoldBuyRes = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: {
...authHeader(f41DefaultBuyBody.token),
'content-type': 'application/json',
},
payload: JSON.stringify({ grams: 0.01 }),
})
assert.equal(f41GoldBuyRes.statusCode, 200)
const f41GoldBuyBody = parseJson<{ achievements: Array<{ id: string; unlocked: boolean }> }>(
f41GoldBuyRes.payload,
)
assert.equal(f41GoldBuyBody.achievements.length, f41DefaultBuyAchCount)
assert.equal(f41GoldBuyBody.achievements.filter((a) => a.id === 'first-gold').length, 1)
assert.equal(f41GoldBuyBody.achievements.find((a) => a.id === 'first-gold')?.unlocked, true)
// --- Phase F0: shared economy helpers regression ---
const {
addXp,
addTransaction,
incrementMission,
unlockAchievement,
} = await import('../src/shared/economy/user-economy.helpers')
const { createDefaultUserState: createDefaultUserStateF0 } = await import(
'../src/shared/catalog/default-user-state'
)
const xpState = createDefaultUserStateF0('f0-xp')
xpState.xp = 950
xpState.maxXp = 100
xpState.level = 1
addXp(xpState, 200)
assert.equal(xpState.level, 8)
assert.equal(xpState.xp, 44)
assert.equal(xpState.maxXp, 266)
const txCapState = createDefaultUserStateF0('f0-tx')
txCapState.transactions = []
for (let i = 0; i < 55; i++) {
addTransaction(txCapState, {
type: 'income',
title: `tx-${i}`,
amount: 1,
unit: 'سکه',
})
}
assert.equal(txCapState.transactions.length, 50)
assert.equal(txCapState.transactions[0].title, 'tx-54')
const missionState = createDefaultUserStateF0('f0-mission')
missionState.missionProgress = 0
missionState.missionTarget = 3
incrementMission(missionState)
incrementMission(missionState)
incrementMission(missionState)
incrementMission(missionState)
assert.equal(missionState.missionProgress, 3)
const achState = createDefaultUserStateF0('f0-ach')
const firstGold = achState.achievements.find((a) => a.id === 'first-gold')
assert.ok(firstGold)
assert.equal(firstGold!.unlocked, true)
firstGold!.unlocked = false
unlockAchievement(achState, 'first-gold')
unlockAchievement(achState, 'first-gold')
assert.equal(firstGold!.unlocked, true)
const buyMissionBody = parseJson<{ user: { missionProgress: number } }>(buyRes.payload)
assert.equal(buyMissionBody.user.missionProgress, 4)
// 23. shutdown closes DB connection
await app.close()
assert.throws(() => dbService.getDb(), /not initialized|Database is not initialized/)
// idempotent migration on fresh app instance
const app2 = await createApp()
const secondMigration = app2.get(DatabaseService).verifyMigrationsApplied()
assert.equal(secondMigration.ok, true)
await app2.close()
console.log('integration tests passed (Phase B + C + D + E + F0 + F1 + F2 + F3 + F4 + F4.1)')
} finally {
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})