Files
gold_land_server/test/lesson-rewards-api.test.ts
safavimohadeseh78andCursor dfb20ca92c
Backend CI / type-check, build, tests (push) Successful in 1m0s
feat: prepare six-lesson trial backend with reliable rewards and operations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:35:42 +03:30

212 lines
7.7 KiB
TypeScript

/**
* Lesson reward rules via HTTP — four replay scenarios on level 6 (inflation).
* Run: npm run test:lesson-rewards
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader'
import type { LessonDefinition, LessonSubmissionAnswer } from '../src/gold-land/lessons/lesson.types'
import { inflationLesson } from '../src/gold-land/lessons/inflation.lesson'
import { DatabaseService } from '../src/database/database.service'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-lesson-rewards-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3995'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
function parseJson<T>(payload: string): T {
return JSON.parse(payload) as T
}
function authHeader(token: string) {
return { authorization: `Bearer ${token}` }
}
function buildPartialCorrect(lesson: LessonDefinition, correctCount: number): LessonSubmissionAnswer[] {
const gradable = getGradableQuestions(lesson)
return gradable.map(({ questionId, step }, index) => {
const isCorrect = index < correctCount
if (step.type === 'sort') {
const order = isCorrect ? [...step.correctOrder] : [...step.correctOrder].reverse()
return { questionId, orderedItemIds: order }
}
if (!isCorrect) {
const wrong =
step.type === 'true_false'
? step.correctAnswerId === 'true'
? 'false'
: 'true'
: step.options.find((o) => o.id !== step.correctAnswerId)?.id ?? 'wrong'
return { questionId, answerId: wrong }
}
return { questionId, answerId: step.correctAnswerId }
})
}
function buildAllCorrect(lesson: LessonDefinition): LessonSubmissionAnswer[] {
return getGradableQuestions(lesson).map(({ questionId, step }) => {
if (step.type === 'sort') {
return { questionId, orderedItemIds: [...step.correctOrder] }
}
return { questionId, answerId: step.correctAnswerId }
})
}
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
)
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
async function registerUser(app: NestFastifyApplication): Promise<{ token: string; userId: string }> {
const username = `lesson-rewards-${Date.now()}`
const res = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username, password: '123456', name: username },
})
assert.ok(res.statusCode === 200 || res.statusCode === 201)
const token = parseJson<{ token: string }>(res.payload).token
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
return { token, userId }
}
function unlockThroughLevel5(db: DatabaseService, userId: string) {
const now = new Date().toISOString()
for (let levelId = 1; levelId <= 5; levelId++) {
db.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, ?, ?, ?)
`).run(userId, levelId, now, now, now)
}
}
function countLedger(db: DatabaseService, userId: string, rewardKey: string): number {
const row = db
.getDb()
.prepare('SELECT COUNT(*) AS c FROM reward_ledger WHERE user_id = ? AND reward_key = ?')
.get(userId, rewardKey) as { c: number }
return row.c
}
type CompletePayload = {
levelId: number
score: number
stars: number
previousStars: number
isFirstCompletion: boolean
bestScoreImproved: boolean
reward: { xp: number; coins: number }
me: { user: { xp: number; coins: number }; progress: { totalStars: number } }
}
async function completeLevel(
app: NestFastifyApplication,
token: string,
answers: LessonSubmissionAnswer[],
): Promise<CompletePayload> {
const res = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/complete',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ answers, durationMs: 4000 }),
})
assert.equal(res.statusCode, 200, res.payload)
return parseJson<CompletePayload>(res.payload)
}
async function runTests() {
const app = await createApp()
const db = app.get(DatabaseService)
try {
const { token, userId } = await registerUser(app)
unlockThroughLevel5(db, userId)
await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/start',
headers: authHeader(token),
})
const THREE = buildPartialCorrect(inflationLesson, 3)
const ALL = buildAllCorrect(inflationLesson)
const FOUR = buildPartialCorrect(inflationLesson, 4)
const me0 = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const base = parseJson<{ user: { xp: number; coins: number } }>(me0.payload)
// 1) First completion with 1★ (score 60)
const first = await completeLevel(app, token, THREE)
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.previousStars, 0)
assert.equal(first.isFirstCompletion, true)
assert.equal(first.bestScoreImproved, true)
assert.equal(first.reward.xp, 40 + 5)
assert.equal(first.reward.coins, 15 + 3)
assert.equal(countLedger(db, userId, 'level:6:first-completion'), 1)
assert.equal(countLedger(db, userId, 'level:6:star:1'), 1)
assert.equal(first.me.progress.mapLevels.find((l) => l.id === 6)?.stars, 1)
// 2) Repeat same score — no extra reward
const repeat = await completeLevel(app, token, THREE)
assert.equal(repeat.stars, 1)
assert.equal(repeat.isFirstCompletion, false)
assert.equal(repeat.bestScoreImproved, false)
assert.equal(repeat.reward.xp, 0)
assert.equal(repeat.reward.coins, 0)
assert.equal(repeat.me.user.xp, base.user.xp + first.reward.xp)
assert.equal(repeat.me.user.coins, base.user.coins + first.reward.coins)
// 3) Improve 1★ → 3★
const improved = await completeLevel(app, token, ALL)
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.previousStars, 1)
assert.equal(improved.bestScoreImproved, true)
assert.equal(improved.reward.xp, 5 + 5)
assert.equal(improved.reward.coins, 3 + 3)
assert.equal(countLedger(db, userId, 'level:6:star:2'), 1)
assert.equal(countLedger(db, userId, 'level:6:star:3'), 1)
// 4) Lower run after best — stars stay 3, no reward
const lower = await completeLevel(app, token, FOUR)
assert.equal(lower.score, 80)
assert.equal(lower.stars, 3)
assert.equal(lower.previousStars, 3)
assert.equal(lower.bestScoreImproved, false)
assert.equal(lower.reward.xp, 0)
assert.equal(lower.reward.coins, 0)
console.log('lesson-rewards-api tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})