Files
gold_land_server/test/compound-interest.test.ts
T
safavimohadeseh78andCursor 866be22395
Backend CI / type-check, build, tests (push) Successful in 1m16s
feat: complete the thirteen-stage financial learning path
Server grades levels 7-13 and keeps castle attempt results separate from stored progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 19:41:24 +03:30

173 lines
7.8 KiB
TypeScript

/**
* 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<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 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)
})