Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions backend/migrations/002_token_rewards.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
-- 002_token_rewards.sql
-- Persistent record of SNAKE tokens minted to players.
--
-- T07 distributes a flat amount per claimed score (placeholder); T08 swaps
-- the conversion for a tiered/multiplier-driven calculation but reuses this
-- table unchanged. The `tier` column is a free-form label so T08 can store
-- 'bronze' / 'silver' / 'gold' / 'legendary' without a schema migration.
--
-- Idempotency: `score_id` carries a UNIQUE constraint so the second claim
-- against the same score returns the existing row instead of inserting a
-- duplicate. Reward reasons unrelated to a single score (top-N bonuses) set
-- `score_id = NULL` and are not constrained by it.

BEGIN;

CREATE TABLE IF NOT EXISTS token_rewards (
id BIGSERIAL PRIMARY KEY,
player_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Nullable: leaderboard-bonus rewards have no single backing score.
score_id BIGINT REFERENCES scores(id) ON DELETE SET NULL,
-- SNAKE amount in nano-units (1 SNAKE = 1_000_000_000 nano). BIGINT so
-- huge legendary multipliers don't overflow a 32-bit integer.
amount_nano BIGINT NOT NULL CHECK (amount_nano >= 0),
reason TEXT NOT NULL CHECK (reason IN ('score','top1','top3','top10')),
-- Conversion-tier label. T07 writes 'flat'; T08 will overwrite with
-- bronze/silver/gold/legendary. Kept as TEXT so the set is open-ended.
tier TEXT NOT NULL DEFAULT 'flat',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Idempotent claim: at most one 'score' reward per scores.id row. Bonuses
-- (where score_id IS NULL) are not constrained by this index.
CREATE UNIQUE INDEX IF NOT EXISTS token_rewards_score_uniq
ON token_rewards (score_id)
WHERE score_id IS NOT NULL;

-- "All my rewards" lookup path.
CREATE INDEX IF NOT EXISTS token_rewards_player_created_idx
ON token_rewards (player_id, created_at DESC);

COMMIT;
8 changes: 6 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,22 @@
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"test": "node --test --import tsx src/**/*.test.ts",
"test": "node --test --import tsx 'src/**/*.test.ts'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@snake/shared": "*",
"express": "^4.21.1",
"pg": "^8.13.1"
"pg": "^8.13.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.16.10",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"pg-mem": "^3.0.4",
"supertest": "^7.0.0",
"tsx": "^4.19.1",
"typescript": "^5.5.4"
}
Expand Down
141 changes: 141 additions & 0 deletions backend/src/__tests__/leaderboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Integration tests for the leaderboard router.
*
* Uses `pg-mem` to provide an in-memory PostgreSQL-compatible database, so
* the suite runs in CI without a real Postgres. The router is wired with the
* mem DB via the `db` option; nothing in the code path under test is mocked.
*/

import { test, before } from 'node:test'
import assert from 'node:assert/strict'
import express from 'express'
import request from 'supertest'
import { newDb } from 'pg-mem'
import { leaderboardRouter } from '../routes/leaderboard.js'
import { runMigrations } from '../db.js'
import type { Db } from '../db.js'

let app: express.Express

before(async () => {
const mem = newDb({ autoCreateForeignKeyIndices: true })
// pg-mem doesn't ship NOW()/jsonb-functions identically to PG; the
// defaults are good enough for our schema.
const adapter = mem.adapters.createPg()
const pool = new adapter.Pool() as unknown as Db

// Apply real migrations (the same .sql files used in production).
await runMigrations(pool)

app = express()
app.use(express.json())
app.use('/api', leaderboardRouter({ db: pool }))
})

test('register -> submit -> leaderboard -> best round-trip', async () => {
// Register two players.
const reg1 = await request(app)
.post('/api/users/register')
.send({ player: 'alice' })
.expect(201)
assert.equal(reg1.body.player, 'alice')
assert.ok(reg1.body.id, 'id present')
assert.ok(reg1.body.token && typeof reg1.body.token === 'string', 'token returned')

const reg2 = await request(app)
.post('/api/users/register')
.send({ player: 'bob' })
.expect(201)

// Submit several scores.
await request(app)
.post('/api/scores')
.set('X-Player-Token', reg1.body.token)
.send({ score: 50 })
.expect(201)

const r2 = await request(app)
.post('/api/scores')
.set('X-Player-Token', reg1.body.token)
.send({ score: 120, meta: { boardSize: 20 } })
.expect(201)
assert.equal(r2.body.entry.score, 120)
assert.equal(r2.body.bestScore, 120, 'best updated to 120')

await request(app)
.post('/api/scores')
.set('X-Player-Token', reg2.body.token)
.send({ score: 80 })
.expect(201)

// Leaderboard should be 120 (alice), 80 (bob), 50 (alice).
const lb = await request(app).get('/api/leaderboard?limit=10').expect(200)
assert.equal(lb.body.entries.length, 3)
assert.equal(lb.body.entries[0].score, 120)
assert.equal(lb.body.entries[0].player, 'alice')
assert.equal(lb.body.entries[0].rank, 1)
assert.equal(lb.body.entries[1].score, 80)
assert.equal(lb.body.entries[2].score, 50)
assert.ok(lb.body.generatedAt, 'generatedAt set')

// Best for alice should be 120.
const best = await request(app)
.get(`/api/users/${reg1.body.id}/best`)
.expect(200)
assert.equal(best.body.entry.score, 120)
assert.equal(best.body.entry.player, 'alice')
})

test('rejects score submit without token', async () => {
await request(app).post('/api/scores').send({ score: 10 }).expect(401)
})

test('rejects score submit with invalid token', async () => {
await request(app)
.post('/api/scores')
.set('X-Player-Token', 'definitely-not-a-real-token')
.send({ score: 10 })
.expect(401)
})

test('rejects negative scores via zod validation', async () => {
const reg = await request(app)
.post('/api/users/register')
.send({ player: 'carol' })
.expect(201)
const r = await request(app)
.post('/api/scores')
.set('X-Player-Token', reg.body.token)
.send({ score: -1 })
.expect(400)
assert.equal(r.body.error, 'validation failed')
})

test('rejects malformed player handle on register', async () => {
await request(app).post('/api/users/register').send({ player: '' }).expect(400)
await request(app)
.post('/api/users/register')
.send({ player: 'has spaces' })
.expect(400)
})

test('register is idempotent on case-insensitive handle', async () => {
const a = await request(app).post('/api/users/register').send({ player: 'Dave' }).expect(201)
const b = await request(app).post('/api/users/register').send({ player: 'dave' }).expect(201)
assert.equal(a.body.id, b.body.id, 'same user returned for case variant')
})

test('leaderboard limit is clamped to a sane range', async () => {
const tooBig = await request(app).get('/api/leaderboard?limit=10000').expect(400)
assert.equal(tooBig.body.error, 'validation failed')
const tooSmall = await request(app).get('/api/leaderboard?limit=0').expect(400)
assert.equal(tooSmall.body.error, 'validation failed')
})

test('users/:id/best returns 404 for unknown user', async () => {
await request(app).get('/api/users/9999999/best').expect(404)
})

test('users/:id/best rejects non-numeric ids', async () => {
await request(app).get('/api/users/not-a-number/best').expect(400)
})
169 changes: 169 additions & 0 deletions backend/src/__tests__/rewards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* Integration tests for the rewards router (T07).
*
* Same pg-mem + supertest pattern as the leaderboard suite. Each test gets
* a fresh in-memory DB so they can register identical handles without
* stepping on each other.
*/

import { test } from 'node:test'
import assert from 'node:assert/strict'
import express from 'express'
import request from 'supertest'
import { newDb } from 'pg-mem'
import { leaderboardRouter } from '../routes/leaderboard.js'
import { rewardsRouter } from '../routes/rewards.js'
import { runMigrations, type Db } from '../db.js'
import { SNAKE_NANO_PER_TOKEN } from '@snake/shared'

async function makeApp(): Promise<express.Express> {
const mem = newDb({ autoCreateForeignKeyIndices: true })
const adapter = mem.adapters.createPg()
const pool = new adapter.Pool() as unknown as Db
await runMigrations(pool)
const app = express()
app.use(express.json())
app.use('/api', leaderboardRouter({ db: pool }))
app.use('/api', rewardsRouter({ db: pool }))
return app
}

async function registerAndScore(app: express.Express, player: string, score: number) {
const reg = await request(app).post('/api/users/register').send({ player }).expect(201)
const token = reg.body.token as string
const sub = await request(app)
.post('/api/scores')
.set('X-Player-Token', token)
.send({ score })
.expect(201)
return { token, userId: reg.body.id as string, scoreId: sub.body.entry.id as string }
}

test('claim mints a reward equal to floor(score) SNAKE', async () => {
const app = await makeApp()
const { token, scoreId } = await registerAndScore(app, 'alice', 120)

const claim = await request(app)
.post('/api/rewards/claim')
.set('X-Player-Token', token)
.send({ scoreId })
.expect(201)

assert.equal(claim.body.alreadyClaimed, false)
assert.equal(claim.body.reward.scoreId, String(scoreId))
assert.equal(claim.body.reward.reason, 'score')
assert.equal(claim.body.reward.tier, 'flat')
assert.equal(claim.body.reward.amountSnake, 120)
assert.equal(claim.body.reward.amountNano, String(BigInt(120) * BigInt(SNAKE_NANO_PER_TOKEN)))
})

test('claim is idempotent — second call returns the existing row', async () => {
const app = await makeApp()
const { token, scoreId } = await registerAndScore(app, 'alice', 75)

const first = await request(app)
.post('/api/rewards/claim')
.set('X-Player-Token', token)
.send({ scoreId })
.expect(201)

const second = await request(app)
.post('/api/rewards/claim')
.set('X-Player-Token', token)
.send({ scoreId })
.expect(200)

assert.equal(second.body.alreadyClaimed, true)
assert.equal(second.body.reward.id, first.body.reward.id)
assert.equal(second.body.reward.amountNano, first.body.reward.amountNano)
})

test('claim rejects scores belonging to another player', async () => {
const app = await makeApp()
await registerAndScore(app, 'alice', 50) // alice exists
const bob = await registerAndScore(app, 'bob', 30)

// alice tries to claim bob's score
const aliceReg = await request(app)
.post('/api/users/register')
.send({ player: 'alice' })
.expect(201)
const aliceToken = aliceReg.body.token

await request(app)
.post('/api/rewards/claim')
.set('X-Player-Token', aliceToken)
.send({ scoreId: bob.scoreId })
.expect(403)
})

test('claim rejects unknown scoreId', async () => {
const app = await makeApp()
const { token } = await registerAndScore(app, 'alice', 10)
await request(app)
.post('/api/rewards/claim')
.set('X-Player-Token', token)
.send({ scoreId: '999999' })
.expect(404)
})

test('claim requires X-Player-Token', async () => {
const app = await makeApp()
const { scoreId } = await registerAndScore(app, 'alice', 10)
await request(app).post('/api/rewards/claim').send({ scoreId }).expect(401)
})

test('claim validates body shape', async () => {
const app = await makeApp()
const { token } = await registerAndScore(app, 'alice', 10)
const r = await request(app)
.post('/api/rewards/claim')
.set('X-Player-Token', token)
.send({ scoreId: 'not-numeric' })
.expect(400)
assert.equal(r.body.error, 'validation failed')
})

test('GET /api/rewards/me returns the player history with totals', async () => {
const app = await makeApp()
const a = await registerAndScore(app, 'alice', 100)
const b = await registerAndScore(app, 'alice', 50) // same player, second score

await request(app)
.post('/api/rewards/claim')
.set('X-Player-Token', a.token)
.send({ scoreId: a.scoreId })
.expect(201)
await request(app)
.post('/api/rewards/claim')
.set('X-Player-Token', b.token)
.send({ scoreId: b.scoreId })
.expect(201)

const me = await request(app)
.get('/api/rewards/me')
.set('X-Player-Token', a.token)
.expect(200)
assert.equal(me.body.rewards.length, 2)
assert.equal(me.body.totalSnake, 150)
assert.equal(me.body.totalNano, String(BigInt(150) * BigInt(SNAKE_NANO_PER_TOKEN)))
})

test('GET /api/rewards/me requires X-Player-Token', async () => {
const app = await makeApp()
await request(app).get('/api/rewards/me').expect(401)
})

test('GET /api/rewards/leaderboard-bonuses returns placeholder 100/50/25 SNAKE', async () => {
const app = await makeApp()
const r = await request(app).get('/api/rewards/leaderboard-bonuses').expect(200)
assert.equal(r.body.bonuses.length, 3)
assert.deepEqual(
r.body.bonuses.map((b: { position: number; amountSnake: number }) => [b.position, b.amountSnake]),
[
[1, 100],
[2, 50],
[3, 25],
],
)
})
Loading