/** * 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(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 { const { AppModule } = await import('../src/app.module') const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() const app = moduleRef.createNestApplication(new FastifyAdapter()) app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), ) app.useGlobalFilters(new ApiExceptionFilter()) await app.init() await app.getHttpAdapter().getInstance().ready() return app } 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 { 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(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) })