Backend CI / type-check, build, tests (push) Successful in 1m0s
Co-authored-by: Cursor <cursoragent@cursor.com>
212 lines
7.7 KiB
TypeScript
212 lines
7.7 KiB
TypeScript
/**
|
|
* Lesson reward rules via HTTP — four replay scenarios on level 6 (inflation).
|
|
* Run: npm run test:lesson-rewards
|
|
*/
|
|
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 { inflationLesson } from '../src/gold-land/lessons/inflation.lesson'
|
|
import { DatabaseService } from '../src/database/database.service'
|
|
|
|
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-lesson-rewards-'))
|
|
process.env.DB_PATH = join(testDir, 'test.db')
|
|
process.env.NODE_ENV = 'test'
|
|
process.env.PORT = '3995'
|
|
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
|
|
|
|
function parseJson<T>(payload: string): T {
|
|
return JSON.parse(payload) as T
|
|
}
|
|
|
|
function authHeader(token: string) {
|
|
return { authorization: `Bearer ${token}` }
|
|
}
|
|
|
|
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 buildAllCorrect(lesson: LessonDefinition): LessonSubmissionAnswer[] {
|
|
return getGradableQuestions(lesson).map(({ questionId, step }) => {
|
|
if (step.type === 'sort') {
|
|
return { questionId, orderedItemIds: [...step.correctOrder] }
|
|
}
|
|
return { questionId, answerId: step.correctAnswerId }
|
|
})
|
|
}
|
|
|
|
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 registerUser(app: NestFastifyApplication): Promise<{ token: string; userId: string }> {
|
|
const username = `lesson-rewards-${Date.now()}`
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/register',
|
|
payload: { username, password: '123456', name: username },
|
|
})
|
|
assert.ok(res.statusCode === 200 || res.statusCode === 201)
|
|
const token = parseJson<{ token: string }>(res.payload).token
|
|
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
|
|
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
|
|
return { token, userId }
|
|
}
|
|
|
|
function unlockThroughLevel5(db: DatabaseService, userId: string) {
|
|
const now = new Date().toISOString()
|
|
for (let levelId = 1; levelId <= 5; levelId++) {
|
|
db.getDb().prepare(`
|
|
INSERT INTO user_level_progress (
|
|
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
|
|
) VALUES (?, ?, 65, 1, 1, ?, ?, ?)
|
|
`).run(userId, levelId, now, now, now)
|
|
}
|
|
}
|
|
|
|
function countLedger(db: DatabaseService, userId: string, rewardKey: string): number {
|
|
const row = db
|
|
.getDb()
|
|
.prepare('SELECT COUNT(*) AS c FROM reward_ledger WHERE user_id = ? AND reward_key = ?')
|
|
.get(userId, rewardKey) as { c: number }
|
|
return row.c
|
|
}
|
|
|
|
type CompletePayload = {
|
|
levelId: number
|
|
score: number
|
|
stars: number
|
|
previousStars: number
|
|
isFirstCompletion: boolean
|
|
bestScoreImproved: boolean
|
|
reward: { xp: number; coins: number }
|
|
me: { user: { xp: number; coins: number }; progress: { totalStars: number } }
|
|
}
|
|
|
|
async function completeLevel(
|
|
app: NestFastifyApplication,
|
|
token: string,
|
|
answers: LessonSubmissionAnswer[],
|
|
): Promise<CompletePayload> {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/gold-land/levels/6/complete',
|
|
headers: { ...authHeader(token), 'content-type': 'application/json' },
|
|
payload: JSON.stringify({ answers, durationMs: 4000 }),
|
|
})
|
|
assert.equal(res.statusCode, 200, res.payload)
|
|
return parseJson<CompletePayload>(res.payload)
|
|
}
|
|
|
|
async function runTests() {
|
|
const app = await createApp()
|
|
const db = app.get(DatabaseService)
|
|
|
|
try {
|
|
const { token, userId } = await registerUser(app)
|
|
unlockThroughLevel5(db, userId)
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/api/gold-land/levels/6/start',
|
|
headers: authHeader(token),
|
|
})
|
|
|
|
const THREE = buildPartialCorrect(inflationLesson, 3)
|
|
const ALL = buildAllCorrect(inflationLesson)
|
|
const FOUR = buildPartialCorrect(inflationLesson, 4)
|
|
|
|
const me0 = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
|
|
const base = parseJson<{ user: { xp: number; coins: number } }>(me0.payload)
|
|
|
|
// 1) First completion with 1★ (score 60)
|
|
const first = await completeLevel(app, token, THREE)
|
|
assert.equal(first.score, 60)
|
|
assert.equal(first.stars, 1)
|
|
assert.equal(first.previousStars, 0)
|
|
assert.equal(first.isFirstCompletion, true)
|
|
assert.equal(first.bestScoreImproved, true)
|
|
assert.equal(first.reward.xp, 40 + 5)
|
|
assert.equal(first.reward.coins, 15 + 3)
|
|
assert.equal(countLedger(db, userId, 'level:6:first-completion'), 1)
|
|
assert.equal(countLedger(db, userId, 'level:6:star:1'), 1)
|
|
assert.equal(first.me.progress.mapLevels.find((l) => l.id === 6)?.stars, 1)
|
|
|
|
// 2) Repeat same score — no extra reward
|
|
const repeat = await completeLevel(app, token, THREE)
|
|
assert.equal(repeat.stars, 1)
|
|
assert.equal(repeat.isFirstCompletion, false)
|
|
assert.equal(repeat.bestScoreImproved, false)
|
|
assert.equal(repeat.reward.xp, 0)
|
|
assert.equal(repeat.reward.coins, 0)
|
|
assert.equal(repeat.me.user.xp, base.user.xp + first.reward.xp)
|
|
assert.equal(repeat.me.user.coins, base.user.coins + first.reward.coins)
|
|
|
|
// 3) Improve 1★ → 3★
|
|
const improved = await completeLevel(app, token, ALL)
|
|
assert.equal(improved.score, 100)
|
|
assert.equal(improved.stars, 3)
|
|
assert.equal(improved.previousStars, 1)
|
|
assert.equal(improved.bestScoreImproved, true)
|
|
assert.equal(improved.reward.xp, 5 + 5)
|
|
assert.equal(improved.reward.coins, 3 + 3)
|
|
assert.equal(countLedger(db, userId, 'level:6:star:2'), 1)
|
|
assert.equal(countLedger(db, userId, 'level:6:star:3'), 1)
|
|
|
|
// 4) Lower run after best — stars stay 3, no reward
|
|
const lower = await completeLevel(app, token, FOUR)
|
|
assert.equal(lower.score, 80)
|
|
assert.equal(lower.stars, 3)
|
|
assert.equal(lower.previousStars, 3)
|
|
assert.equal(lower.bestScoreImproved, false)
|
|
assert.equal(lower.reward.xp, 0)
|
|
assert.equal(lower.reward.coins, 0)
|
|
|
|
console.log('lesson-rewards-api tests passed')
|
|
} finally {
|
|
await app.close()
|
|
try {
|
|
rmSync(testDir, { recursive: true, force: true })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
runTests().catch((error) => {
|
|
console.error(error)
|
|
process.exit(1)
|
|
})
|