Skip to content
Merged
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
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)
})
153 changes: 153 additions & 0 deletions backend/src/repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Data access layer.
*
* All SQL lives here. Routes call these helpers and never assemble SQL
* themselves. Every helper takes a `Db` so routes/tests can pick a connection.
*/

import { randomBytes } from 'node:crypto'
import type { Db } from './db.js'

export interface UserRow {
id: string
player: string
api_token: string
}

export interface ScoreRow {
id: string
user_id: string
score: number
created_at: string // ISO-8601
player: string
}

/**
* Create a new user with a freshly minted API token, or return the existing
* one. We do a SELECT-then-INSERT instead of `ON CONFLICT (LOWER(player))`
* because expression-based unique indexes are well-supported by real
* PostgreSQL but not always by lightweight test adapters (pg-mem). The race
* window is harmless here — at worst two concurrent registrations for the
* same handle will conflict on the unique index and one will retry.
*/
export async function createUser(db: Db, player: string): Promise<UserRow> {
const existing = await db.query<UserRow>(
`SELECT id::text AS id, player, api_token
FROM users
WHERE LOWER(player) = LOWER($1)`,
[player],
)
const found = existing.rows[0]
if (found) return found

const token = randomBytes(24).toString('hex')
const insert = await db.query<UserRow>(
`INSERT INTO users (player, api_token)
VALUES ($1, $2)
RETURNING id::text AS id, player, api_token`,
[player, token],
)
const row = insert.rows[0]
if (!row) throw new Error('failed to create user')
return row
}

/** Resolve a user by their API token. Returns `null` if the token is unknown. */
export async function findUserByToken(db: Db, token: string): Promise<UserRow | null> {
const result = await db.query<UserRow>(
`SELECT id::text AS id, player, api_token
FROM users
WHERE api_token = $1`,
[token],
)
return result.rows[0] ?? null
}

/** Resolve a user by id. */
export async function findUserById(db: Db, id: string): Promise<UserRow | null> {
const result = await db.query<UserRow>(
`SELECT id::text AS id, player, api_token
FROM users
WHERE id = $1`,
[id],
)
return result.rows[0] ?? null
}

/**
* Record a score for the given user. Creates a session row alongside the
* score so we have a 1:1 audit trail even if no `/sessions` endpoint exists
* yet.
*/
export async function recordScore(
db: Db,
userId: string,
score: number,
meta: Record<string, unknown> = {},
): Promise<ScoreRow> {
const session = await db.query<{ id: string }>(
`INSERT INTO sessions (user_id, ended_at, final_score, meta)
VALUES ($1, NOW(), $2, $3::jsonb)
RETURNING id::text AS id`,
[userId, score, JSON.stringify(meta)],
)
const sessionId = session.rows[0]?.id ?? null

const inserted = await db.query<ScoreRow>(
`INSERT INTO scores (user_id, session_id, score)
VALUES ($1, $2, $3)
RETURNING id::text AS id,
user_id::text AS user_id,
score,
created_at,
(SELECT player FROM users WHERE id = $1) AS player`,
[userId, sessionId, score],
)
const row = inserted.rows[0]
if (!row) throw new Error('failed to record score')
// Some adapters return Date objects for TIMESTAMPTZ; normalize to ISO.
return { ...row, created_at: toIso(row.created_at) }
}

/** Fetch the top-N scores for the global leaderboard, newest tie-break wins ascending. */
export async function topScores(db: Db, limit: number): Promise<ScoreRow[]> {
const result = await db.query<ScoreRow>(
`SELECT s.id::text AS id,
s.user_id::text AS user_id,
s.score AS score,
s.created_at AS created_at,
u.player AS player
FROM scores s
JOIN users u ON u.id = s.user_id
ORDER BY s.score DESC, s.created_at ASC
LIMIT $1`,
[limit],
)
return result.rows.map((r) => ({ ...r, created_at: toIso(r.created_at) }))
}

/** Fetch the all-time best score for a single user. Returns `null` if no scores. */
export async function bestScoreForUser(db: Db, userId: string): Promise<ScoreRow | null> {
const result = await db.query<ScoreRow>(
`SELECT s.id::text AS id,
s.user_id::text AS user_id,
s.score AS score,
s.created_at AS created_at,
u.player AS player
FROM scores s
JOIN users u ON u.id = s.user_id
WHERE s.user_id = $1
ORDER BY s.score DESC, s.created_at ASC
LIMIT 1`,
[userId],
)
const row = result.rows[0]
if (!row) return null
return { ...row, created_at: toIso(row.created_at) }
}

function toIso(value: unknown): string {
if (value instanceof Date) return value.toISOString()
if (typeof value === 'string') return value
return new Date(String(value)).toISOString()
}
Loading