Files
recflare/apps/leaderboard/src/test/integration/api.test.ts
T
2026-10-22 14:58:29 -04:00

558 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest'
import { RELATIONSHIP_SCHEMA_DDL, RelationshipType } from '@repo/domain'
import { SCHEMA_DDL } from '../../leaderboard-db'
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The `api` worker's relationship table — the friends filter reads it.
for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
const befriend = (a: number, b: number) =>
env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
.bind(a, b, RelationshipType.Friend)
.run()
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
// into the JWT_SECRET store, so this worker's validation accepts it.
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub: number): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub: String(sub), exp: now + 3600 })
)}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
const post = (path: string, body: unknown, headers: Record<string, string> = {}) =>
SELF.fetch(`https://example.com/leaderboard/${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(body),
})
async function setStat(
player: number,
RoomId: number,
StatValue: number,
CurrentStatValue: number | null = null,
StatChannel = 2
) {
const res = await post(
'CheckAndSetStat',
{ StatChannel, RoomId, StatValue, CurrentStatValue },
await bearer(player)
)
expect(res.status).toBe(200)
// The whole body is the number — not an envelope, not `{ value: 0 }`.
expect(await res.text()).toBe('0')
}
const stored = (player: number, room: number, channel = 2) =>
env.DB.prepare(
'SELECT stat_value FROM leaderboard WHERE player_id = ?1 AND room_id = ?2 AND stat_channel = ?3'
)
.bind(player, room, channel)
.first<{ stat_value: number }>()
it('response with hello world', async () => {
const res = await SELF.fetch('https://example.com')
expect(res.status).toBe(200)
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
})
describe('an empty board', () => {
it('answers GetNearbyScores with an empty row list', async () => {
const res = await post('GetNearbyScores', { PlayerId: 1, RoomId: 999, StatChannel: 1 })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Rows: [] })
})
it('answers GetRanks with an empty row list', async () => {
const res = await post('GetRanks', {
RankStart: 0,
RankEnd: 9,
PlayerId: 2,
StatChannel: 1,
RoomId: 999,
FilterType: 0,
SortAscending: false,
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Rows: [] })
})
it('answers GetPlayerRank with the unranked sentinel and the caller’s own id', async () => {
const res = await post('GetPlayerRank', {
PlayerId: 205,
StatChannel: 2,
RoomId: 999,
FilterType: 0,
SortAscending: false,
})
expect(res.status).toBe(200)
// Three fields, no board selectors: the client pairs the answer with its own question.
// Ranks are 0-based — the client adds one to draw them — so 0 IS first place and the
// sentinel has to be a big number rather than 0.
expect(await res.json()).toEqual({ PlayerId: 205, Score: 0, Rank: 99999 })
})
it('answers GetPlayerRank even when the body is unreadable', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', {
method: 'POST',
body: 'not json',
})
// A board that fails to draw is worse than one that draws the player as unranked.
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ PlayerId: 0, Score: 0, Rank: 99999 })
})
it('answers GetRanks with an empty board when the body is unreadable', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetRanks', {
method: 'POST',
body: 'not json',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Rows: [] })
})
})
describe('CheckAndSetStat', () => {
it('refuses a write with no bearer token', async () => {
const res = await post('CheckAndSetStat', {
StatChannel: 2,
RoomId: 14,
StatValue: 1,
CurrentStatValue: null,
})
expect(res.status).toBe(401)
})
it('stores the caller’s value on the board and answers a bare 0', async () => {
await setStat(42, 14, 3)
expect(await stored(42, 14)).toEqual({ stat_value: 3 })
})
it('overwrites when the client believes nothing is stored', async () => {
await setStat(43, 14, 3)
await setStat(43, 14, 7, null)
expect(await stored(43, 14)).toEqual({ stat_value: 7 })
})
it('writes only when CurrentStatValue matches what is stored', async () => {
await setStat(44, 14, 3)
await setStat(44, 14, 4, 3)
expect(await stored(44, 14)).toEqual({ stat_value: 4 })
// A stale client that still believes 3 is stored doesn't walk the board backwards.
await setStat(44, 14, 1, 3)
expect(await stored(44, 14)).toEqual({ stat_value: 4 })
})
it('keeps rooms and channels apart', async () => {
await setStat(45, 14, 3)
await setStat(45, 15, 9)
await setStat(45, 14, 5, null, 7)
expect(await stored(45, 14)).toEqual({ stat_value: 3 })
expect(await stored(45, 15)).toEqual({ stat_value: 9 })
expect(await stored(45, 14, 7)).toEqual({ stat_value: 5 })
})
})
describe('a scored board', () => {
// Room 100: player 1 has 10, player 2 has 30, player 3 has 20, player 4 has 20 (ties
// break on the lower id, so 3 ranks ahead of 4).
const ROOM = 100
beforeAll(async () => {
await setStat(1, ROOM, 10)
await setStat(2, ROOM, 30)
await setStat(3, ROOM, 20)
await setStat(4, ROOM, 20)
})
it('answers another channel of the same room as its own, empty board', async () => {
const res = await post('GetRanks', {
RankStart: 0,
RankEnd: 9,
PlayerId: 1,
StatChannel: 3,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await res.json()).toEqual({ Rows: [] })
})
it('ranks highest value first with 0-based ranks', async () => {
const res = await post('GetRanks', {
RankStart: 0,
RankEnd: 9,
PlayerId: 1,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 2, Score: 30, Rank: 0 },
{ PlayerId: 3, Score: 20, Rank: 1 },
{ PlayerId: 4, Score: 20, Rank: 2 },
{ PlayerId: 1, Score: 10, Rank: 3 },
],
})
})
it('pages the board by rank, inclusive at both ends', async () => {
const res = await post('GetRanks', {
RankStart: 2,
RankEnd: 3,
PlayerId: 1,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 4, Score: 20, Rank: 2 },
{ PlayerId: 1, Score: 10, Rank: 3 },
],
})
})
it('ranks lowest first when asked to sort ascending', async () => {
const res = await post('GetRanks', {
RankStart: 1,
RankEnd: 2,
PlayerId: 1,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: true,
})
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 3, Score: 20, Rank: 1 },
{ PlayerId: 4, Score: 20, Rank: 2 },
],
})
})
it('answers a player’s own rank consistently with the page', async () => {
const res = await post('GetPlayerRank', {
PlayerId: 4,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await res.json()).toEqual({ PlayerId: 4, Score: 20, Rank: 2 })
})
it('answers a player with no row in the room as unranked', async () => {
const res = await post('GetPlayerRank', {
PlayerId: 77,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await res.json()).toEqual({ PlayerId: 77, Score: 0, Rank: 99999 })
})
it('answers the rows around a player, clamped to the board', async () => {
const res = await post('GetNearbyScores', {
PlayerId: 4,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
WindowSize: 1,
})
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 3, Score: 20, Rank: 1 },
{ PlayerId: 4, Score: 20, Rank: 2 },
{ PlayerId: 1, Score: 10, Rank: 3 },
],
})
})
it('answers the top of the board around a player who isn’t on it', async () => {
const res = await post('GetNearbyScores', {
PlayerId: 77,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
WindowSize: 1,
})
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 2, Score: 30, Rank: 0 },
{ PlayerId: 3, Score: 20, Rank: 1 },
{ PlayerId: 4, Score: 20, Rank: 2 },
],
})
})
})
describe('the friends filter', () => {
// Room 200, channel 2: players 11..15 score 50, 40, 30, 20, 10. Player 14 is friends
// with 11 (14 requested) and 15 (15 requested); 12 and 13 are strangers, and 16 is a
// friend with no score. Globally 14 is 4th (`Rank` 3); among friends 2nd (`Rank` 1).
const ROOM = 200
const board = (extra: object) => ({
StatChannel: 2,
RoomId: ROOM,
FilterType: 1,
SortAscending: false,
...extra,
})
beforeAll(async () => {
await setStat(11, ROOM, 50)
await setStat(12, ROOM, 40)
await setStat(13, ROOM, 30)
await setStat(14, ROOM, 20)
await setStat(15, ROOM, 10)
await befriend(14, 11)
await befriend(15, 14)
await befriend(14, 16)
// A pending request is not a friendship.
await env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (14, 12, ?1)'
)
.bind(RelationshipType.FriendRequestSent)
.run()
})
it('ranks the viewer among their friends on GetRanks', async () => {
const res = await post('GetRanks', board({ PlayerId: 14, RankStart: 0, RankEnd: 9 }))
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 11, Score: 50, Rank: 0 },
{ PlayerId: 14, Score: 20, Rank: 1 },
{ PlayerId: 15, Score: 10, Rank: 2 },
],
})
})
it('gives the friends rank on GetPlayerRank', async () => {
const res = await post('GetPlayerRank', board({ PlayerId: 14 }))
expect(await res.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 1 })
const global = await post('GetPlayerRank', board({ PlayerId: 14, FilterType: 0 }))
expect(await global.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 3 })
})
it('centres GetNearbyScores on the viewer within their friends', async () => {
const res = await post('GetNearbyScores', board({ PlayerId: 14, WindowSize: 10 }))
expect(await res.json()).toEqual({
Rows: [
{ PlayerId: 11, Score: 50, Rank: 0 },
{ PlayerId: 14, Score: 20, Rank: 1 },
{ PlayerId: 15, Score: 10, Rank: 2 },
],
})
})
it('shows a player with no friends only themself', async () => {
const res = await post('GetRanks', board({ PlayerId: 13, RankStart: 0, RankEnd: 9 }))
expect(await res.json()).toEqual({ Rows: [{ PlayerId: 13, Score: 30, Rank: 0 }] })
})
})
describe('the nearby window', () => {
// Room 300: players 21..45 score 25..1, so 25 rows with player 33 in the middle (rank 12,
// the 13th row — ranks are 0-based).
const ROOM = 300
beforeAll(async () => {
for (let p = 21; p <= 45; p++) await setStat(p, ROOM, 46 - p)
})
it('caps WindowSize at 10 either side', async () => {
const res = await post('GetNearbyScores', {
PlayerId: 33,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
WindowSize: 100,
})
const { Rows } = (await res.json()) as { Rows: { Rank: number }[] }
expect(Rows).toHaveLength(21)
expect(Rows[0]?.Rank).toBe(2)
expect(Rows[20]?.Rank).toBe(22)
})
// The slice the client actually asks for: 0..9 is TEN rows, starting at the top. Read as
// 1-based this served nine rows starting at the second one.
it('serves ten rows for the client’s RankStart 0, RankEnd 9', async () => {
const res = await post('GetRanks', {
RankStart: 0,
RankEnd: 9,
PlayerId: 33,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
const { Rows } = (await res.json()) as {
Rows: Array<{ PlayerId: number; Score: number; Rank: number }>
}
expect(Rows).toHaveLength(10)
expect(Rows[0]).toEqual({ PlayerId: 21, Score: 25, Rank: 0 })
expect(Rows[9]?.Rank).toBe(9)
})
it('defaults WindowSize to 10 when absent', async () => {
const res = await post('GetNearbyScores', {
PlayerId: 33,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
const { Rows } = (await res.json()) as { Rows: unknown[] }
expect(Rows).toHaveLength(21)
})
})
describe('the 2026 body shape', () => {
// 20260323 renamed the read selectors: `PrimaryKey` is the room, `SecondaryKey` the
// channel, and `LeaderboardType` 0 says it's a room board. Same room, same board as the
// 2025 body. `CheckAndSetStat` did NOT change.
const ROOM = 400
const body2026 = (extra: object) => ({
PlayerId: 51,
PrimaryKey: ROOM,
SecondaryKey: 1,
FilterType: 0,
SortAscending: false,
LeaderboardType: 0,
...extra,
})
const ROWS = {
Rows: [
{ PlayerId: 52, Score: 9, Rank: 0 },
{ PlayerId: 51, Score: 7, Rank: 1 },
],
}
beforeAll(async () => {
await setStat(51, ROOM, 7, null, 1)
await setStat(52, ROOM, 9, null, 1)
})
it('reads the board 2025’s RoomId + StatChannel name', async () => {
const legacy = await post('GetNearbyScores', {
WindowSize: 10,
PlayerId: 51,
StatChannel: 1,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
expect(await legacy.json()).toEqual(ROWS)
const modern = await post('GetNearbyScores', body2026({ WindowSize: 10 }))
expect(await modern.json()).toEqual(ROWS)
const ranks = await post('GetRanks', body2026({ RankStart: 0, RankEnd: 9 }))
expect(await ranks.json()).toEqual(ROWS)
const rank = await post('GetPlayerRank', body2026({}))
expect(await rank.json()).toEqual({ PlayerId: 51, Score: 7, Rank: 1 })
})
it('ignores RoomId/StatChannel once a 2026 key is present', async () => {
// The 2026 keys name no board here, so the stale 2025 ones must not be used instead.
const res = await post(
'GetRanks',
body2026({ PrimaryKey: null, RoomId: ROOM, StatChannel: 1, RankStart: 0, RankEnd: 9 })
)
expect(await res.json()).toEqual({ Rows: [] })
})
it('answers a null-keyed Event board on GetPlayerRank with the full object', async () => {
const res = await post(
'GetPlayerRank',
body2026({ PrimaryKey: null, SecondaryKey: null, LeaderboardType: 1 })
)
// 20260323 throws "Response was empty" on null/empty/204: always the three keys.
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ PlayerId: 51, Score: 0, Rank: 99999 })
})
it('answers an unranked player with the full object', async () => {
const res = await post('GetPlayerRank', body2026({ PlayerId: 53 }))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ PlayerId: 53, Score: 0, Rank: 99999 })
})
it('answers null keys and Event boards with an empty Rows, never a bare array', async () => {
for (const extra of [{ PrimaryKey: null }, { SecondaryKey: null }, { LeaderboardType: 1 }]) {
const ranks = await post('GetRanks', body2026({ RankStart: 0, RankEnd: 9, ...extra }))
expect(ranks.status).toBe(200)
expect(await ranks.text()).toBe('{"Rows":[]}')
const nearby = await post('GetNearbyScores', body2026({ WindowSize: 10, ...extra }))
expect(nearby.status).toBe(200)
expect(await nearby.text()).toBe('{"Rows":[]}')
}
})
it('still writes CheckAndSetStat by RoomId + StatChannel, answering a bare 0', async () => {
const res = await post(
'CheckAndSetStat',
{ StatChannel: 1, RoomId: ROOM, StatValue: 12, CurrentStatValue: 7 },
await bearer(51)
)
// The whole body is the number: not `{"result":0}`, not an envelope.
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.text()).toBe('0')
expect(await stored(51, ROOM, 1)).toEqual({ stat_value: 12 })
})
it('does not write CheckAndSetStat by the read-side PrimaryKey/SecondaryKey', async () => {
const res = await post(
'CheckAndSetStat',
{ PrimaryKey: ROOM + 1, SecondaryKey: 1, StatValue: 5, CurrentStatValue: null },
await bearer(51)
)
expect(await res.text()).toBe('0')
expect(await stored(51, ROOM + 1, 1)).toBeNull()
})
})
it('serves an openapi spec with no dangling refs', async () => {
const res = await SELF.fetch('https://example.com/openapi.json')
expect(res.status).toBe(200)
const spec = (await res.json()) as Record<string, unknown>
expect(Object.keys(spec.paths as object).sort()).toEqual([
'/',
'/leaderboard/CheckAndSetStat',
'/leaderboard/GetNearbyScores',
'/leaderboard/GetPlayerRank',
'/leaderboard/GetRanks',
])
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
})