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
168 changes: 168 additions & 0 deletions src/type-sense/type-sense.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> | 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<Array<CollectionFieldSchema>> {
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 }),
);
});
});
9 changes: 9 additions & 0 deletions src/type-sense/type-sense.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions src/utilities/isRoleAbove.spec.ts
Original file line number Diff line number Diff line change
@@ -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));
}
}
});
});
4 changes: 4 additions & 0 deletions src/utilities/isRoleAbove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Loading