/** * Castle exam — 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 { gradeCastleExam } from '../src/gold-land/castle-exam/castle.grader' import { castleExamPublic, getCastleQuestionBank } from '../src/gold-land/castle-exam/castle.mission' import { GOLD_LAND_LEVELS } from '../src/gold-land/levels/levels' 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 = 'castle-test-user' const testDir = mkdtempSync(join(tmpdir(), 'gold-land-castle-')) process.env.DB_PATH = join(testDir, 'test.db') process.env.NODE_ENV = 'test' process.env.PORT = '4001' process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production' const correctIds = ['exchange', 'regular', 'water', 'moves', 'no', 'less', 'valid', 'keep', 'no', '121', 'b', '6'] function answersFor(count: number) { return getCastleQuestionBank().map((question, index) => ({ questionId: question.id, answerId: index < count ? question.correctOptionId : question.options.find((option) => option.id !== question.correctOptionId)!.id, })) } 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() { const scores = Array.from({ length: 13 }, (_, count) => gradeCastleExam(answersFor(count)).score) assert.deepEqual(scores, [0, 8, 17, 25, 33, 42, 50, 58, 67, 75, 83, 92, 100]) assert.equal(gradeCastleExam(answersFor(7)).feedback.completed, false) assert.equal(gradeCastleExam(answersFor(8)).feedback.completed, true) assert.equal(JSON.stringify(castleExamPublic).includes('correctOptionId'), false) assert.equal(JSON.stringify(castleExamPublic).includes('explanation'), false) assert.deepEqual(castleExamPublic.questions.map((question) => question.sourceLevelId), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) assert.throws(() => gradeCastleExam(answersFor(12).slice(0, 11))) assert.throws(() => gradeCastleExam([...answersFor(12), answersFor(1)[0]!])) assert.throws(() => gradeCastleExam(answersFor(12).map((answer, index) => (index === 0 ? { ...answer, answerId: 'nope' } : answer)))) assert.throws(() => gradeCastleExam(answersFor(12).map((answer) => ({ ...answer, score: 100 })))) assert.deepEqual(correctIds, getCastleQuestionBank().map((question) => question.correctOptionId)) 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 (?, ?, 100, 3, 1, ?, ?, ?) `) for (let id = 1; id <= 11; id += 1) insert.run(USER, id, now, now, now) assert.throws(() => progression.startLevel(USER, 13)) insert.run(USER, 12, now, now, now) assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'available') const coinsOf = () => JSON.parse((db.prepare('SELECT state_json FROM users WHERE id = ?').get(USER) as { state_json: string }).state_json).coins as number const beforeCoins = coinsOf() const failed = progression.completeLevel(USER, 13, { answers: answersFor(7) }) assert.equal(failed.score, 58) assert.equal(failed.stars, 0) assert.equal(failed.reward.coins, 0) assert.equal(failed.unlockedLevelId, null) assert.equal(failed.feedback && 'completed' in failed.feedback && failed.feedback.completed, false) const row = db.prepare('SELECT completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { completed_at: string | null } | undefined assert.equal(row?.completed_at ?? null, null) assert.equal(coinsOf(), beforeCoins) const first = progression.completeLevel(USER, 13, { answers: answersFor(8) }) assert.equal(first.score, 67) assert.equal(first.stars, 1) assert.equal(first.reward.xp, 100 + 5) assert.equal(first.reward.coins, 50 + 3) assert.equal(first.unlockedLevelId, null) const saved = db.prepare('SELECT best_score, stars, completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { best_score: number; stars: number; completed_at: string } const afterPass = progression.completeLevel(USER, 13, { answers: answersFor(7) }) assert.equal(afterPass.score, 58) assert.equal(afterPass.reward.coins, 0) assert.equal(afterPass.feedback && 'completed' in afterPass.feedback && afterPass.feedback.completed, false) assert.equal(afterPass.previousStars, 1) const still = db.prepare('SELECT best_score, stars, completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { best_score: number; stars: number; completed_at: string } assert.equal(still.best_score, saved.best_score) assert.equal(still.stars, saved.stars) assert.equal(still.completed_at, saved.completed_at) const replay = progression.completeLevel(USER, 13, { answers: answersFor(8) }) assert.equal(replay.reward.coins, 0) const improved = progression.completeLevel(USER, 13, { answers: answersFor(12) }) assert.equal(improved.score, 100) assert.equal(improved.stars, 3) assert.equal(improved.reward.xp, 10) assert.equal(improved.reward.coins, 6) assert.equal(improved.unlockedLevelId, null) assert.equal(GOLD_LAND_LEVELS.length, 13) assert.equal(progression.buildLevelItems(USER).some((level) => level.id === 14), false) const parallelId = 'castle-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 <= 12; id += 1) insert.run(parallelId, id, now, now, now) const { JwtService } = await import('@nestjs/jwt') const token = app.get(JwtService).sign({ userId: parallelId }) const body = { answers: answersFor(11) } const [left, right] = await Promise.all([ app.inject({ method: 'POST', url: '/api/gold-land/levels/13/complete', headers: { authorization: `Bearer ${token}` }, payload: body }), app.inject({ method: 'POST', url: '/api/gold-land/levels/13/complete', headers: { authorization: `Bearer ${token}` }, payload: body }), ]) assert.equal(left.statusCode, 200) assert.equal(right.statusCode, 200) const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { coins: number; xp: number }; unlockedLevelId: number | null }) assert.equal(bodies.reduce((sum, item) => sum + item.reward.coins, 0), 50 + 9) assert.equal(bodies.reduce((sum, item) => sum + item.reward.xp, 0), 100 + 15) assert.equal(bodies.every((item) => item.unlockedLevelId === null), true) console.log('castle-exam tests passed') } finally { await app.close() try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ } } } run().catch((error) => { console.error(error) process.exit(1) })