/** * Budgeting mission — 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 { BudgetingGradingError, gradeBudgetSubmission } from '../src/gold-land/budgeting/budgeting.grader' import { GoldLandProgressionService } from '../src/gold-land/services/progression.service' import { whatIsMoneyLesson } from '../src/gold-land/lessons/what-is-money.lesson' import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader' import type { DatabaseService } from '../src/database/database.service' import { createDefaultUserState } from '../src/shared/catalog/default-user-state' const USER = 'budget-test-user' const testDir = mkdtempSync(join(tmpdir(), 'gold-land-budget-')) process.env.DB_PATH = join(testDir, 'test.db') process.env.NODE_ENV = 'test' process.env.PORT = '3996' process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production' function plan(transport: number, food: number, savings: number, fun: number) { return { transport, food, savings, fun } } function answers(initial: ReturnType, finalPlan: ReturnType) { return [ { questionId: 'initial', allocations: initial }, { questionId: 'final', allocations: finalPlan }, ] } 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 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 perfectA = gradeBudgetSubmission(answers(plan(20, 25, 20, 35), plan(30, 25, 20, 25))) const perfectB = gradeBudgetSubmission(answers(plan(25, 30, 25, 0), plan(35, 30, 20, 0))) assert.equal(perfectA.score, 100) assert.equal(perfectB.score, 100) const shortSavings = gradeBudgetSubmission(answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0))) assert.equal(shortSavings.score, 80) assert.equal(shortSavings.feedback.plans[0]?.criteria.find((c) => c.id === 'savings-goal')?.earned, false) assert.equal(shortSavings.feedback.plans[1]?.criteria.find((c) => c.id === 'transport')?.earned, true) assert.throws( () => gradeBudgetSubmission(answers(plan(20, 25, 20, 0), plan(20, 25, 20, 0))), (error: unknown) => error instanceof BudgetingGradingError && error.kind === 'unmet-needs' && error.statusCode === 422, ) assert.throws( () => gradeBudgetSubmission(answers(plan(0, 0, 20, 0), plan(30, 25, 20, 0))), (error: unknown) => error instanceof BudgetingGradingError && error.kind === 'unmet-needs', ) for (const bad of [ answers(plan(-5, 25, 20, 0), plan(30, 25, 20, 0)), answers(plan(20.5 as unknown as number, 25, 20, 0), plan(30, 25, 20, 0)), answers(plan(12, 25, 20, 0), plan(30, 25, 20, 0)), answers(plan(50, 50, 25, 25), plan(30, 25, 20, 0)), ]) { assert.throws(() => gradeBudgetSubmission(bad)) } assert.throws(() => gradeBudgetSubmission([ { questionId: 'initial', allocations: { ...plan(20, 25, 20, 0), extra: 5 } as never }, { questionId: 'final', allocations: plan(30, 25, 20, 0) }, ]), ) db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run( USER, JSON.stringify(createDefaultUserState(USER)), new Date().toISOString(), ) const now = new Date().toISOString() 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 (?, ?, 70, 1, 1, ?, ?, ?) `) for (let id = 1; id <= 5; id += 1) insert.run(USER, id, now, now, now) assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 7)?.status, 'locked') assert.throws(() => progression.startLevel(USER, 7)) insert.run(USER, 6, now, now, now) assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 7)?.status, 'available') assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'locked') const lessonAnswers = getGradableQuestions(whatIsMoneyLesson).map(({ questionId, step }) => step.type === 'sort' ? { questionId, orderedItemIds: [...step.correctOrder] } : { questionId, answerId: step.correctAnswerId }, ) const lesson = progression.completeLevel(USER, 1, { answers: lessonAnswers }) assert.equal(lesson.score, 100) assert.equal(lesson.feedback, undefined) const first = progression.completeLevel(USER, 7, { answers: answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)), }) assert.equal(first.score, 80) assert.equal(first.stars, 2) assert.equal(first.isFirstCompletion, true) assert.equal(first.reward.xp, 45 + 10) assert.equal(first.reward.coins, 18 + 6) const same = progression.completeLevel(USER, 7, { answers: answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)), }) assert.equal(same.isFirstCompletion, false) assert.equal(same.reward.xp, 0) assert.equal(same.reward.coins, 0) const improved = progression.completeLevel(USER, 7, { answers: answers(plan(20, 25, 20, 0), plan(30, 25, 20, 0)), }) assert.equal(improved.score, 100) assert.equal(improved.stars, 3) assert.equal(improved.reward.xp, 5) assert.equal(improved.reward.coins, 3) assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'available') assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 9)?.status, 'locked') const reg = await app.inject({ method: 'POST', url: '/api/auth/register', payload: { username: `budget_http_${Date.now()}`, password: 'pass1234', name: 'Budget' }, }) assert.equal(reg.statusCode, 200) const token = JSON.parse(reg.payload) as { token: string } const bad = await app.inject({ method: 'POST', url: '/api/gold-land/levels/7/complete', headers: { authorization: `Bearer ${token.token}` }, payload: { answers: [ { questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0, bonus: 1 } }, { questionId: 'final', allocations: plan(30, 25, 20, 0) }, ], }, }) assert.equal(bad.statusCode, 400) const badBody = JSON.parse(bad.payload) as { error: string } assert.match(badBody.error, /property bonus should not exist|نباید/) const parallelId = 'budget-parallel-user' db.prepare('DELETE FROM reward_ledger WHERE user_id = ?').run(parallelId) db.prepare('DELETE FROM user_level_progress WHERE user_id = ?').run(parallelId) db.prepare('DELETE FROM users WHERE id = ?').run(parallelId) db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run( parallelId, JSON.stringify(createDefaultUserState(parallelId)), now, ) for (let id = 1; id <= 6; id += 1) insert.run(parallelId, id, now, now, now) const { JwtService } = await import('@nestjs/jwt') const parallelToken = app.get(JwtService).sign({ userId: parallelId }) const parallelPayload = { answers: answers(plan(20, 25, 20, 0), plan(30, 25, 20, 0)), } const [firstRace, secondRace] = await Promise.all([ app.inject({ method: 'POST', url: '/api/gold-land/levels/7/complete', headers: { authorization: `Bearer ${parallelToken}` }, payload: parallelPayload, }), app.inject({ method: 'POST', url: '/api/gold-land/levels/7/complete', headers: { authorization: `Bearer ${parallelToken}` }, payload: parallelPayload, }), ]) assert.equal(firstRace.statusCode, 200) assert.equal(secondRace.statusCode, 200) const raceBodies = [firstRace, secondRace].map( (response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number }; stars: number }, ) assert.equal(raceBodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9) assert.equal(raceBodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15) const ledger = db.prepare( 'SELECT reward_key, xp_delta, coins_delta FROM reward_ledger WHERE user_id = ? ORDER BY reward_key', ).all(parallelId) as Array<{ reward_key: string; xp_delta: number; coins_delta: number }> assert.deepEqual(ledger.map((row) => row.reward_key), [ 'level:7:first-completion', 'level:7:star:1', 'level:7:star:2', 'level:7:star:3', ]) const me = await app.inject({ method: 'GET', url: '/api/me', headers: { authorization: `Bearer ${parallelToken}` }, }) const meBody = JSON.parse(me.payload) as { user: { coins: number } } assert.equal(meBody.user.coins, 1350 + 18 + 9) console.log('budgeting tests passed') } finally { await app.close() try { rmSync(testDir, { recursive: true, force: true }) } catch { // Windows may keep the sqlite handle briefly. } } } run().catch((error) => { console.error(error) process.exit(1) })