2026-08-02 08:08:11 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)
|
2026-10-22 19:41:24 +03:30
|
|
|
|
assert.equal(levelsBody.levels.filter((l) => l.isActive).length, 13)
|
2026-08-02 08:08:11 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-12 14:35:42 +03:30
|
|
|
|
const { createDefaultUserState } = await import(
|
2026-08-02 08:08:11 +02:00
|
|
|
|
'../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)
|
|
|
|
|
|
})
|