diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 461e8a1..b3e8db5 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -41,6 +41,7 @@ jobs: env: CLIENT_SECRET: "secret-token" JWT_SECRET: "jwt-secret" + POSTGRES_USER: readonly POSTGRES_PASSWORD: ${{ secrets.POSTGRES_READONLY_PASSWORD }} run: bun run test --coverage --coverageReporters=lcov --coverageReporters=html --bail=false --testTimeout=30000 --ci continue-on-error: true diff --git a/open-api/openapi.json b/open-api/openapi.json index 1e9a532..6da799a 100644 --- a/open-api/openapi.json +++ b/open-api/openapi.json @@ -3000,7 +3000,13 @@ { "schema": { "type": "string", - "enum": ["clears", "freshClears", "sherpas", "speedrun"] + "enum": [ + "clears", + "freshClears", + "sherpas", + "speedrun", + "powerRankings" + ] }, "required": true, "name": "category", diff --git a/src/data-access-layer/__test__/leaderboard/power-rankings.test.ts b/src/data-access-layer/__test__/leaderboard/power-rankings.test.ts new file mode 100644 index 0000000..4bf3a33 --- /dev/null +++ b/src/data-access-layer/__test__/leaderboard/power-rankings.test.ts @@ -0,0 +1,49 @@ +import { z } from "zod" +import { cleanupPostgresAfterAll } from "../../../routes/testUtil" +import { zIndividualLeaderboardEntry } from "../../../schema/components/LeaderboardData" +import { zNaturalNumber } from "../../../schema/util" +import { + getIndividualWorldFirstPowerRankingsLeaderboard, + searchIndividualWorldFirstPowerRankingsLeaderboard +} from "../../leaderboard/individual/power-rankings" + +cleanupPostgresAfterAll() + +describe("getIndividualWorldFirstPowerRankingsLeaderboard", () => { + it("returns the correct shape", async () => { + const data = await getIndividualWorldFirstPowerRankingsLeaderboard({ + skip: 24921, + take: 27 + }).catch(console.error) + + const parsed = z.array(zIndividualLeaderboardEntry).safeParse(data) + if (!parsed.success) { + expect(parsed.error.errors).toHaveLength(0) + } else { + expect(parsed.data.length).toBeGreaterThan(0) + expect(parsed.success).toBe(true) + } + }) +}) + +describe("searchIndividualWorldFirstPowerRankingsLeaderboard", () => { + it("returns the correct shape", async () => { + const data = await searchIndividualWorldFirstPowerRankingsLeaderboard({ + take: 4, + membershipId: "4611686018488107374" + }).catch(console.error) + + const parsed = z + .object({ + page: zNaturalNumber(), + entries: z.array(zIndividualLeaderboardEntry) + }) + .safeParse(data) + if (!parsed.success) { + expect(parsed.error.errors).toHaveLength(0) + } else { + expect(parsed.data.entries.length).toBeGreaterThan(0) + expect(parsed.success).toBe(true) + } + }) +}) diff --git a/src/data-access-layer/leaderboard/individual/power-rankings.ts b/src/data-access-layer/leaderboard/individual/power-rankings.ts new file mode 100644 index 0000000..be12799 --- /dev/null +++ b/src/data-access-layer/leaderboard/individual/power-rankings.ts @@ -0,0 +1,64 @@ +import { IndividualLeaderboardEntry } from "../../../schema/components/LeaderboardData" +import { postgres } from "../../../services/postgres" + +export const getIndividualWorldFirstPowerRankingsLeaderboard = async ({ + skip, + take +}: { + skip: number + take: number +}) => { + return await postgres.queryRows( + `SELECT + world_first_player_rankings.position, + world_first_player_rankings.rank, + ROUND(world_first_player_rankings.score::numeric, 3) AS "value", + JSONB_BUILD_OBJECT( + 'membershipId', membership_id::text, + 'membershipType', membership_type, + 'iconPath', icon_path, + 'displayName', display_name, + 'bungieGlobalDisplayName', bungie_global_display_name, + 'bungieGlobalDisplayNameCode', bungie_global_display_name_code, + 'lastSeen', last_seen, + 'isPrivate', is_private + ) as "playerInfo" + FROM world_first_player_rankings + JOIN player USING (membership_id) + WHERE position > $1 AND position <= ($1 + $2) + ORDER BY position ASC`, + { + params: [skip, take], + fetchCount: take + } + ) +} + +export const searchIndividualWorldFirstPowerRankingsLeaderboard = async ({ + membershipId, + take +}: { + membershipId: bigint | string + take: number +}) => { + const result = await postgres.queryRow<{ position: number }>( + `SELECT position + FROM world_first_player_rankings + WHERE membership_id = $1::bigint + ORDER BY position ASC + LIMIT 1`, + { + params: [membershipId] + } + ) + if (!result) return null + + const page = Math.ceil(result.position / take) + return { + page, + entries: await getIndividualWorldFirstPowerRankingsLeaderboard({ + skip: (page - 1) * take, + take + }) + } +} diff --git a/src/routes/leaderboard/individual/global.test.ts b/src/routes/leaderboard/individual/global.test.ts index 7417a8d..b19eb1f 100644 --- a/src/routes/leaderboard/individual/global.test.ts +++ b/src/routes/leaderboard/individual/global.test.ts @@ -46,6 +46,28 @@ describe("global leaderboard 200", () => { search: "4611686018488107374" } )) + + test("power rankings", () => + t( + { + category: "powerRankings" + }, + { + count: 14, + page: 4 + } + )) + + test("search power rankings", () => + t( + { + category: "powerRankings" + }, + { + count: 11, + search: "4611686018488107374" + } + )) }) describe("global leaderboard 404", () => { diff --git a/src/routes/leaderboard/individual/global.ts b/src/routes/leaderboard/individual/global.ts index 3e79f76..1f8544b 100644 --- a/src/routes/leaderboard/individual/global.ts +++ b/src/routes/leaderboard/individual/global.ts @@ -2,26 +2,26 @@ import { z } from "zod" import { RaidHubRoute } from "../../../RaidHubRoute" import { getIndividualGlobalLeaderboard, - individualGlobalLeaderboardSortColumns, searchIndividualGlobalLeaderboard } from "../../../data-access-layer/leaderboard/individual/global" +import { + getIndividualWorldFirstPowerRankingsLeaderboard, + searchIndividualWorldFirstPowerRankingsLeaderboard +} from "../../../data-access-layer/leaderboard/individual/power-rankings" import { cacheControl } from "../../../middlewares/cache-control" import { zLeaderboardData } from "../../../schema/components/LeaderboardData" import { ErrorCode } from "../../../schema/errors/ErrorCode" import { zLeaderboardPagination } from "../../../schema/query.ts/LeaderboardPagination" import { zBigIntString } from "../../../schema/util" -const zCategory = z.enum(["clears", "freshClears", "sherpas", "speedrun"]) +const zCategory = z.enum(["clears", "freshClears", "sherpas", "speedrun", "powerRankings"]) -const categoryMap: Record< - z.infer, - (typeof individualGlobalLeaderboardSortColumns)[number] -> = { +const categoryMap = { clears: "clears", freshClears: "fresh_clears", sherpas: "sherpas", speedrun: "speed" -} +} as const export const leaderboardIndividualGlobalRoute = new RaidHubRoute({ method: "get", @@ -52,11 +52,16 @@ export const leaderboardIndividualGlobalRoute = new RaidHubRoute({ const { page, count, search } = req.query if (search) { - const data = await searchIndividualGlobalLeaderboard({ - membershipId: search, - take: count, - column: categoryMap[category] - }) + const data = await (category === "powerRankings" + ? searchIndividualWorldFirstPowerRankingsLeaderboard({ + membershipId: search, + take: count + }) + : searchIndividualGlobalLeaderboard({ + membershipId: search, + take: count, + column: categoryMap[category] + })) if (!data) { return RaidHubRoute.fail(ErrorCode.PlayerNotOnLeaderboardError, { @@ -72,11 +77,16 @@ export const leaderboardIndividualGlobalRoute = new RaidHubRoute({ entries: data.entries }) } else { - const entries = await getIndividualGlobalLeaderboard({ - skip: (page - 1) * count, - take: count, - column: categoryMap[category] - }) + const entries = await (category === "powerRankings" + ? getIndividualWorldFirstPowerRankingsLeaderboard({ + skip: (page - 1) * count, + take: count + }) + : getIndividualGlobalLeaderboard({ + skip: (page - 1) * count, + take: count, + column: categoryMap[category] + })) return RaidHubRoute.ok({ type: "individual" as const,