Backend CI / type-check, build, tests (push) Successful in 1m16s
Server grades levels 7-13 and keeps castle attempt results separate from stored progress. Co-authored-by: Cursor <cursoragent@cursor.com>
234 lines
9.7 KiB
TypeScript
234 lines
9.7 KiB
TypeScript
/**
|
|
* 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<typeof plan>, finalPlan: ReturnType<typeof plan>) {
|
|
return [
|
|
{ questionId: 'initial', allocations: initial },
|
|
{ questionId: 'final', allocations: finalPlan },
|
|
]
|
|
}
|
|
|
|
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() {
|
|
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)
|
|
})
|