/** * Gold Land progression tests — always uses a temporary SQLite file. * Run: npm run test:progression */ 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 { ProgressionError } from '../src/gold-land/exceptions/progression.exception' import { GoldLandProgressionService } from '../src/gold-land/services/progression.service' import { RewardService } from '../src/gold-land/services/reward.service' import type { DatabaseService } from '../src/database/database.service' import { createDefaultUserState } from '../src/shared/catalog/default-user-state' const TEST_USER = 'progression-test-user' const testDir = mkdtempSync(join(tmpdir(), 'gold-land-nest-progression-')) process.env.DB_PATH = join(testDir, 'test.db') process.env.NODE_ENV = 'test' process.env.PORT = '3998' process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production' async function createApp(): Promise { const { AppModule } = await import('../src/app.module') const moduleRef = await Test.createTestingModule({ imports: [AppModule], }).compile() const app = moduleRef.createNestApplication(new FastifyAdapter()) app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true, }), ) app.useGlobalFilters(new ApiExceptionFilter()) await app.init() await app.getHttpAdapter().getInstance().ready() return app } function resetTestUser(dbService: DatabaseService) { const db = dbService.getDb() db.prepare('DELETE FROM users WHERE id = ?').run(TEST_USER) db.prepare('DELETE FROM user_level_progress WHERE user_id = ?').run(TEST_USER) db.prepare('DELETE FROM reward_ledger WHERE user_id = ?').run(TEST_USER) const state = createDefaultUserState(TEST_USER) db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run( TEST_USER, JSON.stringify(state), new Date().toISOString(), ) } function grantStars(dbService: DatabaseService, levelId: number, stars: number, score: number) { const now = new Date().toISOString() dbService.getDb().prepare(` INSERT INTO user_level_progress ( user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at ) VALUES (?, ?, ?, ?, 1, ?, ?, ?) ON CONFLICT(user_id, level_id) DO UPDATE SET best_score = excluded.best_score, stars = excluded.stars, completed_at = excluded.completed_at, updated_at = excluded.updated_at `).run(TEST_USER, levelId, score, stars, now, now, now) } 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 } }) } 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 expectError(fn: () => unknown, statusCode: number) { try { fn() assert.fail('Expected error') } catch (error) { assert.ok(error instanceof ProgressionError) assert.equal(error.statusCode, statusCode) } } async function runTests() { const app = await createApp() const dbService = (await import('../src/database/database.service')).DatabaseService const database = app.get(dbService) const progression = app.get(GoldLandProgressionService) const rewards = app.get(RewardService) const { getAllPlayableLessons, countGradableQuestions } = await import( '../src/gold-land/lessons/lesson.registry' ) const { loadLessonForLevel } = await import('../src/gold-land/lessons/lesson.loader') const { sanitizeLesson } = await import('../src/gold-land/lessons/lesson.sanitizer') const { gradeLessonSubmission, LessonGradingError } = await import( '../src/gold-land/lessons/lesson.grader' ) const { inflationLesson } = await import('../src/gold-land/lessons/inflation.lesson') const { needsAndWantsLesson } = await import('../src/gold-land/lessons/needs-and-wants.lesson') const { isLevelUnlocked, starsFromScore } = await import( '../src/gold-land/services/progression.service' ) try { resetTestUser(database) let items = progression.buildLevelItems(TEST_USER) const playable = getAllPlayableLessons() assert.equal(playable.length, 7) for (const lesson of playable) { assert.equal(countGradableQuestions(lesson), 5, `${lesson.id} should have 5 gradable questions`) } assert.equal(isLevelUnlocked(1, new Map()), true) assert.notEqual(items.find((l) => l.id === 1)?.status, 'locked') assert.equal(items.find((l) => l.id === 2)?.status, 'locked') assert.equal(items.find((l) => l.id === 6)?.status, 'locked') expectError(() => progression.startLevel(TEST_USER, 6), 403) expectError( () => progression.completeLevel(TEST_USER, 6, { answers: buildAllCorrect(inflationLesson) }), 403, ) for (let id = 1; id <= 5; id++) { grantStars(database, id, 1, 65) } items = progression.buildLevelItems(TEST_USER) assert.equal(items.find((l) => l.id === 6)?.status, 'available') const ALL_CORRECT_INFLATION = buildAllCorrect(inflationLesson) const THREE_CORRECT_INFLATION = buildPartialCorrect(inflationLesson, 3) const FOUR_CORRECT_INFLATION = buildPartialCorrect(inflationLesson, 4) const ONE_CORRECT_INFLATION = buildPartialCorrect(inflationLesson, 1) try { progression.completeLevel(TEST_USER, 6, { answers: [{ questionId: 'unknown-q', answerId: 'option-b' }] }) assert.fail('expected invalid question') } catch (error) { assert.ok(error instanceof ProgressionError) assert.equal(error.statusCode, 400) } try { progression.completeLevel(TEST_USER, 6, { answers: [{ questionId: 'inflation-q1', answerId: 'invalid-option' }], }) assert.fail('expected invalid answer') } catch (error) { assert.ok(error instanceof ProgressionError) assert.equal(error.statusCode, 400) } try { gradeLessonSubmission(inflationLesson, [ { questionId: 'inflation-q1', answerId: 'option-b' }, { questionId: 'inflation-q1', answerId: 'option-a' }, ]) assert.fail('expected duplicate questionId') } catch (error) { assert.ok(error instanceof LessonGradingError) assert.equal(error.statusCode, 400) } try { gradeLessonSubmission(needsAndWantsLesson, [ { questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'unknown', 'shoes', 'toy', 'crown'], }, ]) assert.fail('expected unknown sort item') } catch (error) { assert.ok(error instanceof LessonGradingError) assert.equal(error.statusCode, 400) } try { gradeLessonSubmission(needsAndWantsLesson, [ { questionId: 'needs-q3', orderedItemIds: ['water', 'water', 'medicine', 'shoes', 'toy', 'crown'], }, ]) assert.fail('expected duplicate sort item') } catch (error) { assert.ok(error instanceof LessonGradingError) assert.equal(error.statusCode, 400) } assert.equal(gradeLessonSubmission(inflationLesson, THREE_CORRECT_INFLATION).score, 60) assert.equal(gradeLessonSubmission(inflationLesson, FOUR_CORRECT_INFLATION).score, 80) assert.equal(gradeLessonSubmission(inflationLesson, ALL_CORRECT_INFLATION).score, 100) assert.equal(starsFromScore(59), 0) assert.equal(starsFromScore(60), 1) assert.equal(starsFromScore(74), 1) assert.equal(starsFromScore(75), 2) assert.equal(starsFromScore(89), 2) assert.equal(starsFromScore(90), 3) try { progression.completeLevel(TEST_USER, 6, { answers: ONE_CORRECT_INFLATION }) assert.fail('expected fail below 60') } catch (error) { assert.ok(error instanceof ProgressionError) assert.equal(error.statusCode, 400) } progression.startLevel(TEST_USER, 6) const first = progression.completeLevel(TEST_USER, 6, { answers: THREE_CORRECT_INFLATION, durationMs: 84000, }) assert.equal(first.stars, 1) assert.equal(first.score, 60) assert.ok(first.isFirstCompletion) assert.ok(first.bestScoreImproved) assert.ok(rewards.hasReward(TEST_USER, 'level:6:first-completion')) const duplicate = progression.completeLevel(TEST_USER, 6, { answers: THREE_CORRECT_INFLATION }) assert.equal(duplicate.isFirstCompletion, false) assert.equal(duplicate.reward.xp, 0) assert.equal(duplicate.reward.coins, 0) try { progression.completeLevel(TEST_USER, 6, { answers: ONE_CORRECT_INFLATION }) assert.fail('expected low score rejection') } catch (error) { assert.ok(error instanceof ProgressionError) } assert.equal(progression.buildLevelItems(TEST_USER).find((l) => l.id === 6)!.stars, 1) const improved = progression.completeLevel(TEST_USER, 6, { answers: ALL_CORRECT_INFLATION }) assert.equal(improved.stars, 3) assert.ok(improved.bestScoreImproved) assert.ok(improved.reward.xp > 0) assert.ok(rewards.hasReward(TEST_USER, 'level:6:star:3')) const replayLower = progression.completeLevel(TEST_USER, 6, { answers: FOUR_CORRECT_INFLATION }) assert.equal(replayLower.stars, 3) assert.equal(replayLower.reward.xp, 0) const detail = progression.getGoldLandLevel(TEST_USER, 6) const json = JSON.stringify(detail) assert.ok(!json.includes('correctAnswerId')) assert.ok(!json.includes('correctOrder')) assert.ok(!json.includes('answerKey')) const sanitized = sanitizeLesson(loadLessonForLevel('inflation')!) for (const step of sanitized.steps) { assert.ok(!('correctAnswerId' in step)) assert.ok(!('correctOrder' in step)) } const moneyLesson = loadLessonForLevel('what-is-money')! const moneyAnswers = buildAllCorrect(moneyLesson) try { progression.completeLevel(TEST_USER, 6, { answers: moneyAnswers }) assert.fail('expected wrong lesson answers for level 6') } catch (error) { assert.ok(error instanceof ProgressionError) assert.equal(error.statusCode, 400) } resetTestUser(database) progression.startLevel(TEST_USER, 1) const level1Answers = buildAllCorrect(loadLessonForLevel('what-is-money')!) const level1Result = progression.completeLevel(TEST_USER, 1, { answers: level1Answers }) assert.equal(level1Result.stars, 3) assert.equal(level1Result.unlockedLevelId, 2) assert.equal(progression.buildLevelItems(TEST_USER).find((l) => l.id === 2)!.status, 'available') const level1Again = progression.completeLevel(TEST_USER, 1, { answers: level1Answers }) assert.equal(level1Again.isFirstCompletion, false) assert.equal(level1Again.reward.xp, 0) console.log('progression tests passed') } finally { await app.close() try { rmSync(testDir, { recursive: true, force: true }) } catch { // ignore } } } runTests().catch((error) => { console.error(error) process.exit(1) })