Files
gold_land_server/test/integration.test.ts
T

2231 lines
85 KiB
TypeScript
Raw Normal View History

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