From 1bc5e39e73cb72d0c5ed988502c255e153a1a0d7 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 14:55:10 -0400 Subject: [PATCH] feature: sortable player role rank in search --- src/type-sense/type-sense.service.spec.ts | 168 ++++++++++++++++++++++ src/type-sense/type-sense.service.ts | 9 ++ src/utilities/isRoleAbove.spec.ts | 31 ++++ src/utilities/isRoleAbove.ts | 4 + 4 files changed, 212 insertions(+) create mode 100644 src/type-sense/type-sense.service.spec.ts create mode 100644 src/utilities/isRoleAbove.spec.ts diff --git a/src/type-sense/type-sense.service.spec.ts b/src/type-sense/type-sense.service.spec.ts new file mode 100644 index 00000000..b9be8d3e --- /dev/null +++ b/src/type-sense/type-sense.service.spec.ts @@ -0,0 +1,168 @@ +import { CollectionFieldSchema } from "typesense/lib/Typesense/Collection"; +import { TypeSenseService } from "./type-sense.service"; +import { RefreshAllPlayersJob } from "./jobs/RefreshAllPlayers"; + +describe("TypeSenseService player role rank", () => { + const roleRankField = { + name: "role_rank", + type: "int32", + optional: true, + sort: true, + index: true, + }; + + let hasura: { query: jest.Mock }; + let matchAssistant: { sendServerMatchId: jest.Mock }; + let reindexQueue: { add: jest.Mock }; + let service: TypeSenseService; + + beforeEach(() => { + hasura = { query: jest.fn() }; + matchAssistant = { sendServerMatchId: jest.fn() }; + reindexQueue = { add: jest.fn().mockResolvedValue({}) }; + + service = new TypeSenseService( + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + { get: jest.fn() } as any, + hasura as any, + matchAssistant as any, + reindexQueue as any, + { add: jest.fn() } as any, + { query: jest.fn() } as any, + ); + }); + + function playersCollection( + existingFields: Array> | null, + ) { + const players = { + exists: jest.fn().mockResolvedValue(existingFields !== null), + retrieve: jest.fn().mockResolvedValue({ fields: existingFields }), + update: jest.fn().mockResolvedValue({}), + }; + const create = jest.fn().mockResolvedValue({}); + + (service as any).client = { + collections: jest.fn((name?: string) => { + return name === "players" ? players : { create }; + }), + }; + + return { players, create }; + } + + async function declaredFields(): Promise> { + const { create } = playersCollection(null); + + await service.createPlayerCollection(); + + reindexQueue.add.mockClear(); + + return create.mock.calls[0][0].fields; + } + + function asTypesenseReturnsIt(field: CollectionFieldSchema) { + const index = field.index ?? true; + const sortableByDefault = ["int32", "int64", "float", "bool"].includes( + field.type, + ); + + return { + ...field, + index, + optional: field.optional ?? false, + sort: field.sort ?? (index && sortableByDefault), + }; + } + + function expectPlayerRefreshQueued() { + expect(reindexQueue.add).toHaveBeenCalledWith( + RefreshAllPlayersJob.name, + {}, + expect.objectContaining({ jobId: RefreshAllPlayersJob.name }), + ); + } + + it("declares a sortable role_rank beside the role string", async () => { + const fields = await declaredFields(); + + expect(fields).toContainEqual(roleRankField); + expect(fields.find((field) => field.name === "role")).toEqual({ + name: "role", + type: "string", + optional: true, + index: true, + }); + }); + + it("adds role_rank to an existing collection and queues a player refresh without dropping role", async () => { + const existing = (await declaredFields()) + .filter((field) => field.name !== "role_rank") + .map(asTypesenseReturnsIt); + const { players, create } = playersCollection(existing); + + await service.createPlayerCollection(); + + expect(create).not.toHaveBeenCalled(); + expect(players.update).toHaveBeenCalledTimes(1); + expect(players.update).toHaveBeenCalledWith({ fields: [roleRankField] }); + expect(reindexQueue.add).toHaveBeenCalledTimes(1); + expectPlayerRefreshQueued(); + }); + + it("leaves a collection that already has role_rank alone", async () => { + const existing = (await declaredFields()).map(asTypesenseReturnsIt); + const { players } = playersCollection(existing); + + await service.createPlayerCollection(); + + expect(players.update).not.toHaveBeenCalled(); + expect(reindexQueue.add).not.toHaveBeenCalled(); + }); + + it.each([ + ["administrator", 6], + ["moderator", 3], + ["user", 0], + [null, 0], + ])("upserts role %s with role_rank %i", async (role, expected) => { + const upsert = jest.fn().mockResolvedValue({}); + + (service as any).client = { + collections: jest.fn(() => ({ + documents: jest.fn(() => ({ upsert })), + })), + }; + hasura.query + .mockResolvedValueOnce({ + players_by_pk: { + elo: {}, + name: "Player", + role, + country: null, + avatar_url: null, + custom_avatar_url: null, + roster_image_url: null, + profile_url: null, + is_banned: false, + is_gagged: false, + is_muted: false, + teams: [], + last_sign_in_at: null, + wins: 0, + losses: 0, + total_matches: 0, + stats: { kills: 0, deaths: 0 }, + sanctions_aggregate: { aggregate: { count: 0 } }, + }, + }) + .mockResolvedValueOnce({ match_lineup_players: [] }); + + await service.updatePlayer("76561198000000000"); + + expect(upsert).toHaveBeenCalledTimes(1); + expect(upsert.mock.calls[0][0]).toEqual( + expect.objectContaining({ role, role_rank: expected }), + ); + }); +}); diff --git a/src/type-sense/type-sense.service.ts b/src/type-sense/type-sense.service.ts index d423eba9..77419341 100644 --- a/src/type-sense/type-sense.service.ts +++ b/src/type-sense/type-sense.service.ts @@ -13,6 +13,7 @@ import { InjectQueue } from "@nestjs/bullmq"; import { Queue } from "bullmq"; import { PostgresService } from "../postgres/postgres.service"; import { RefreshAllPlayersJob } from "./jobs/RefreshAllPlayers"; +import { roleRank } from "../utilities/isRoleAbove"; // One publicly visible lineup, as the global search bar needs it. Only ever // produced by searchableUtilityLineups, which is the one place the visibility @@ -163,6 +164,13 @@ export class TypeSenseService { index: true, }, { name: "role", type: "string", optional: true, index: true }, + { + name: "role_rank", + type: "int32", + optional: true, + sort: true, + index: true, + }, { name: "kills", type: "int32", optional: true }, { name: "deaths", type: "int32", optional: true }, { name: "wins", type: "int32", optional: true }, @@ -613,6 +621,7 @@ export class TypeSenseService { Object.assign({}, player, elo, { id: steamId, steam_id: steamId, + role_rank: roleRank(player.role), is_registered: isRegistered, elo: TypeSenseService.primaryElo( elo.elo_competitive, diff --git a/src/utilities/isRoleAbove.spec.ts b/src/utilities/isRoleAbove.spec.ts new file mode 100644 index 00000000..cb4e9549 --- /dev/null +++ b/src/utilities/isRoleAbove.spec.ts @@ -0,0 +1,31 @@ +import { e_player_roles_enum } from "generated"; +import { isRoleAbove, roleRank, rolesAtOrAbove } from "./isRoleAbove"; + +describe("roleRank", () => { + it.each([ + ["user", 0], + ["verified_user", 1], + ["streamer", 2], + ["moderator", 3], + ["match_organizer", 4], + ["tournament_organizer", 5], + ["administrator", 6], + ["unknown_role", 0], + [null, 0], + [undefined, 0], + ])("ranks %s as %i", (role, expected) => { + expect(roleRank(role as e_player_roles_enum)).toBe(expected); + }); + + it("ranks every role in the order isRoleAbove gates on", () => { + const roles = rolesAtOrAbove("user"); + + expect(roles.map(roleRank)).toEqual(roles.map((_, index) => index)); + + for (const a of roles) { + for (const b of roles) { + expect(roleRank(a) >= roleRank(b)).toBe(isRoleAbove(a, b)); + } + } + }); +}); diff --git a/src/utilities/isRoleAbove.ts b/src/utilities/isRoleAbove.ts index 090b9755..53193be6 100644 --- a/src/utilities/isRoleAbove.ts +++ b/src/utilities/isRoleAbove.ts @@ -30,3 +30,7 @@ export function rolesAtOrAbove( return index === -1 ? [] : roleOrder.slice(index); } + +export function roleRank(role: e_player_roles_enum | null | undefined): number { + return Math.max(0, roleOrder.indexOf(role)); +}