139 lines
7.0 KiB
TypeScript
139 lines
7.0 KiB
TypeScript
/**
|
|||
|
|
* Smart shop — 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 { finalCost } from '../src/gold-land/smart-shop/shop.math'
|
||
|
|
import { gradeSmartShopSubmission } from '../src/gold-land/smart-shop/shop.grader'
|
||
|
|
import { smartShopMission } from '../src/gold-land/smart-shop/shop.mission'
|
||
|
|
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 = 'shop-test-user'
|
||
|
|
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-shop-'))
|
||
|
|
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'
|
||
|
|
|
||
|
|
const correct = [
|
||
|
|
{ questionId: 'notebook-choice', answerId: 'b' },
|
||
|
|
{ questionId: 'notebook-why', answerId: 'fits-and-cheaper' },
|
||
|
|
{ questionId: 'discount-choice', answerId: 'b' },
|
||
|
|
{ questionId: 'discount-why', answerId: 'pay-amount' },
|
||
|
|
{ questionId: 'info-choice', answerId: 'ask' },
|
||
|
|
{ questionId: 'info-why', answerId: 'incomplete' },
|
||
|
|
]
|
||
|
|
const wrong = ['a', 'cheapest-tag', 'a', 'bigger-discount', 'buy-now', 'hurry']
|
||
|
|
|
||
|
|
function withCount(count: number) {
|
||
|
|
return correct.map((answer, index) => (index < count ? answer : { questionId: answer.questionId, answerId: wrong[index]! }))
|
||
|
|
}
|
||
|
|
|
||
|
|
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() {
|
||
|
|
assert.equal(finalCost({ price: 40, delivery: 5 }), 45)
|
||
|
|
assert.equal(finalCost({ price: 38, delivery: 12 }), 50)
|
||
|
|
assert.equal(finalCost({ price: 30, delivery: 0 }), 30)
|
||
|
|
assert.equal(finalCost({ price: 28, delivery: 0 }), 28)
|
||
|
|
assert.equal(finalCost({ price: 45, delivery: null }), null)
|
||
|
|
assert.notEqual(finalCost({ price: 45, delivery: null }), 0)
|
||
|
|
|
||
|
|
const scores = [0, 1, 2, 3, 4, 5, 6].map((count) => gradeSmartShopSubmission(withCount(count)).score)
|
||
|
|
assert.deepEqual(scores, [0, 17, 33, 50, 67, 83, 100])
|
||
|
|
assert.throws(() => gradeSmartShopSubmission(correct.slice(0, 5)))
|
||
|
|
assert.throws(() => gradeSmartShopSubmission([...correct, correct[0]!]))
|
||
|
|
assert.throws(() => gradeSmartShopSubmission(correct.map((answer, index) => (index === 0 ? { ...answer, answerId: 'ask' } : answer))))
|
||
|
|
assert.throws(() => gradeSmartShopSubmission(correct.map((answer) => ({ ...answer, price: 1 }))))
|
||
|
|
|
||
|
|
const payload = JSON.stringify(smartShopMission)
|
||
|
|
assert.equal(payload.includes('correctAnswerId'), false)
|
||
|
|
assert.equal(payload.includes('"explanation"'), false)
|
||
|
|
const info = smartShopMission.missions[2]?.cards[0]
|
||
|
|
assert.equal(info?.delivery, null)
|
||
|
|
assert.equal(info?.finalCost, null)
|
||
|
|
|
||
|
|
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 (?, ?, 80, 2, 1, ?, ?, ?)
|
||
|
|
`)
|
||
|
|
for (let id = 1; id <= 9; id += 1) insert.run(USER, id, now, now, now)
|
||
|
|
assert.throws(() => progression.startLevel(USER, 11))
|
||
|
|
insert.run(USER, 10, now, now, now)
|
||
|
|
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'available')
|
||
|
|
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'locked')
|
||
|
|
|
||
|
|
assert.throws(() => progression.completeLevel(USER, 11, { answers: withCount(3) }))
|
||
|
|
const first = progression.completeLevel(USER, 11, { answers: withCount(4) })
|
||
|
|
assert.equal(first.score, 67)
|
||
|
|
assert.equal(first.stars, 1)
|
||
|
|
assert.equal(first.reward.xp, 45 + 5)
|
||
|
|
assert.equal(first.reward.coins, 18 + 3)
|
||
|
|
const replay = progression.completeLevel(USER, 11, { answers: withCount(4) })
|
||
|
|
assert.equal(replay.reward.coins, 0)
|
||
|
|
const improved = progression.completeLevel(USER, 11, { answers: correct })
|
||
|
|
assert.equal(improved.score, 100)
|
||
|
|
assert.equal(improved.stars, 3)
|
||
|
|
assert.equal(improved.reward.xp, 10)
|
||
|
|
assert.equal(improved.reward.coins, 6)
|
||
|
|
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'available')
|
||
|
|
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'locked')
|
||
|
|
|
||
|
|
const parallelId = 'shop-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 <= 10; id += 1) insert.run(parallelId, id, now, now, now)
|
||
|
|
const { JwtService } = await import('@nestjs/jwt')
|
||
|
|
const token = app.get(JwtService).sign({ userId: parallelId })
|
||
|
|
const [left, right] = await Promise.all([
|
||
|
|
app.inject({ method: 'POST', url: '/api/gold-land/levels/11/complete', headers: { authorization: `Bearer ${token}` }, payload: { answers: correct } }),
|
||
|
|
app.inject({ method: 'POST', url: '/api/gold-land/levels/11/complete', headers: { authorization: `Bearer ${token}` }, payload: { answers: correct } }),
|
||
|
|
])
|
||
|
|
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 } })
|
||
|
|
assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9)
|
||
|
|
assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15)
|
||
|
|
const bad = await app.inject({
|
||
|
|
method: 'POST',
|
||
|
|
url: '/api/gold-land/levels/11/complete',
|
||
|
|
headers: { authorization: `Bearer ${token}` },
|
||
|
|
payload: { answers: [{ questionId: 'notebook-choice', answerId: 'b', score: 100 }] },
|
||
|
|
})
|
||
|
|
assert.equal(bad.statusCode, 400)
|
||
|
|
console.log('smart-shop tests passed')
|
||
|
|
} finally {
|
||
|
|
await app.close()
|
||
|
|
try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
run().catch((error) => {
|
||
|
|
console.error(error)
|
||
|
|
process.exit(1)
|
||
|
|
})
|