Initial commit: NestJS backend for Child Gold Land
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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<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
|
||||
}
|
||||
|
||||
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, 6)
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user