/** * Compound-interest lesson — temporary SQLite only. */ 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 { buildGrowthExample } from '../src/gold-land/lessons/growth-example' import { compoundInterestLesson } from '../src/gold-land/lessons/compound-interest.lesson' import { gradeLessonSubmission } from '../src/gold-land/lessons/lesson.grader' import { sanitizeLesson } from '../src/gold-land/lessons/lesson.sanitizer' import { GoldLandProgressionService } from '../src/gold-land/services/progression.service' import type { DatabaseService } from '../src/database/database.service' import { createDefaultUserState } from '../src/shared/catalog/default-user-state' const USER = 'compound-test-user' const testDir = mkdtempSync(join(tmpdir(), 'gold-land-compound-')) process.env.DB_PATH = join(testDir, 'test.db') process.env.NODE_ENV = 'test' process.env.PORT = '3997' process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production' const correct = [ { questionId: 'compound-q1', answerId: 'on-balance' }, { questionId: 'compound-q2', answerId: '121' }, { questionId: 'compound-q3', answerId: 'from-110' }, { questionId: 'compound-q4', answerId: 'compound' }, { questionId: 'compound-q5', answerId: 'false' }, ] const wrong = ['initial-only', '120', 'from-100', 'simple', 'true'] function withCount(count: number) { return correct.map((answer, index) => index < count ? answer : { questionId: answer.questionId, answerId: wrong[index] ?? 'true' }, ) } function ends(methodId: 'simple' | 'compound') { return buildGrowthExample().periods.map((period) => period.rows.find((row) => row.methodId === methodId)?.endTenths) } 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 run() { assert.deepEqual(ends('simple'), [1000, 1100, 1200, 1300]) assert.deepEqual(ends('compound'), [1000, 1100, 1210, 1331]) const period2 = buildGrowthExample().periods[2] const compound2 = period2?.rows.find((row) => row.methodId === 'compound') assert.equal(compound2?.startTenths, 1100) assert.equal(compound2?.growthTenths, 110) assert.equal(compound2?.endTenths, 1210) const before = JSON.stringify(buildGrowthExample().periods) const viewed = buildGrowthExample().periods.slice(0, 2) assert.equal(viewed.length, 2) assert.equal(JSON.stringify(buildGrowthExample().periods), before) assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(0)).score, 0) assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(2)).score, 40) assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(3)).score, 60) assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(4)).score, 80) assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(5)).score, 100) const perfect = gradeLessonSubmission(compoundInterestLesson, correct) assert.equal(perfect.feedback?.questions.length, 5) assert.equal(perfect.feedback?.questions.every((item) => item.earned), true) assert.throws(() => gradeLessonSubmission(compoundInterestLesson, correct.slice(0, 4))) assert.throws(() => gradeLessonSubmission(compoundInterestLesson, [...correct, correct[0]!])) assert.throws(() => gradeLessonSubmission(compoundInterestLesson, [{ questionId: 'missing', answerId: '121' }])) assert.throws(() => gradeLessonSubmission(compoundInterestLesson, correct.map((answer) => ({ ...answer, orderedItemIds: ['x'] }))), ) const publicLesson = JSON.stringify(sanitizeLesson(compoundInterestLesson)) assert.equal(publicLesson.includes('correctAnswerId'), false) assert.equal(publicLesson.includes('explanation'), false) assert.equal(publicLesson.includes('1331'), true) const app = await createApp() const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService const progression = app.get(GoldLandProgressionService) const db = database.getDb() try { const now = new Date().toISOString() db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run( USER, JSON.stringify(createDefaultUserState(USER)), now, ) const insert = db.prepare(` INSERT INTO user_level_progress ( user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at ) VALUES (?, ?, 80, 2, 1, ?, ?, ?) `) for (let id = 1; id <= 8; id += 1) insert.run(USER, id, now, now, now) assert.throws(() => progression.startLevel(USER, 10)) insert.run(USER, 9, now, now, now) assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'available') assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'locked') assert.throws(() => progression.completeLevel(USER, 10, { answers: withCount(2) })) const first = progression.completeLevel(USER, 10, { answers: withCount(3) }) assert.equal(first.score, 60) assert.equal(first.stars, 1) assert.equal(first.reward.xp, 45 + 5) assert.equal(first.reward.coins, 18 + 3) const replay = progression.completeLevel(USER, 10, { answers: withCount(3) }) assert.equal(replay.reward.xp, 0) assert.equal(replay.reward.coins, 0) const improved = progression.completeLevel(USER, 10, { answers: correct }) assert.equal(improved.score, 100) assert.equal(improved.stars, 3) assert.equal(improved.reward.xp, 10) assert.equal(improved.reward.coins, 6) assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'available') assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'locked') const parallelId = 'compound-parallel-user' db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run( parallelId, JSON.stringify(createDefaultUserState(parallelId)), now, ) for (let id = 1; id <= 9; id += 1) insert.run(parallelId, id, now, now, now) const { JwtService } = await import('@nestjs/jwt') const token = app.get(JwtService).sign({ userId: parallelId }) const [left, right] = await Promise.all([ app.inject({ method: 'POST', url: '/api/gold-land/levels/10/complete', headers: { authorization: `Bearer ${token}` }, payload: { answers: correct }, }), app.inject({ method: 'POST', url: '/api/gold-land/levels/10/complete', headers: { authorization: `Bearer ${token}` }, payload: { answers: correct }, }), ]) assert.equal(left.statusCode, 200) assert.equal(right.statusCode, 200) const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number } }) assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9) assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15) console.log('compound-interest tests passed') } finally { await app.close() try { rmSync(testDir, { recursive: true, force: true }) } catch { // ignore } } } run().catch((error) => { console.error(error) process.exit(1) })