/** * 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 { const { AppModule } = await import('../src/app.module') const moduleRef = await Test.createTestingModule({ imports: [AppModule], }).compile() const app = moduleRef.createNestApplication(new FastifyAdapter()) app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true, }), ) app.useGlobalFilters(new ApiExceptionFilter()) await app.init() await app.getHttpAdapter().getInstance().ready() return app } function authHeader(token: string) { return { authorization: `Bearer ${token}` } } function parseJson(payload: string): T { return JSON.parse(payload) as T } function assertMeResponseShape(body: Record) { 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 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 }>(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 }>(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>(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, 6) 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: createDefaultUserStateF0 } = 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) })