feat: add extensive test suite
This commit is contained in:
@@ -5,72 +5,31 @@ import Snowflake, { GatewayIntents } from "@sfjs/snowflake";
|
||||
import { forbidden } from "next/navigation";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { redis } from "@/lib/db/redis";
|
||||
import {
|
||||
createDiscordMemberAddHandler,
|
||||
createDiscordUsersService,
|
||||
type DiscordUser,
|
||||
} from "./service";
|
||||
|
||||
const bot = new Snowflake({
|
||||
token: env.DISCORD_BOT_TOKEN || "",
|
||||
intents: [GatewayIntents.GUILDS, GatewayIntents.GUILD_MEMBERS],
|
||||
});
|
||||
|
||||
type User = {
|
||||
nick: string | null;
|
||||
user: {
|
||||
global_name: string | null;
|
||||
username: string;
|
||||
id: string;
|
||||
};
|
||||
const cache = {
|
||||
getCached: () => redis!.get("userlist"),
|
||||
setCached: (value: string) => redis!.set("userlist", value),
|
||||
};
|
||||
|
||||
bot.on("guildMemberAdd", async (member: User) => {
|
||||
const cached: { username: string; uid: string; display: string }[] =
|
||||
JSON.parse((await redis!.get("userlist")) ?? "[]");
|
||||
if (cached.some((m) => m.uid === member.user.id))
|
||||
cached.push({
|
||||
username: member.user.username,
|
||||
display: member.nick ?? member.user.global_name ?? member.user.username,
|
||||
uid: member.user.id,
|
||||
});
|
||||
redis!.set("userlist", JSON.stringify(cached));
|
||||
});
|
||||
bot.on("guildMemberAdd", createDiscordMemberAddHandler(cache));
|
||||
|
||||
export async function getDiscordUsers() {
|
||||
if (!(await adminCheck())) forbidden();
|
||||
const members: { username: string; uid: string; display: string }[] = [];
|
||||
let after = "0";
|
||||
|
||||
const cached = await redis!.get("userlist");
|
||||
if (cached)
|
||||
return JSON.parse(cached) as {
|
||||
username: string;
|
||||
uid: string;
|
||||
display: string;
|
||||
}[];
|
||||
|
||||
while (true) {
|
||||
// fetch up to 1000 members after the last user id
|
||||
const batch: User[] = await bot.guilds[env.DISCORD_GUILD_ID!].members({
|
||||
export const getDiscordUsers = createDiscordUsersService({
|
||||
adminCheck,
|
||||
forbidden,
|
||||
...cache,
|
||||
getMembers: (after) =>
|
||||
bot.guilds[env.DISCORD_GUILD_ID!].members({
|
||||
limit: 1000,
|
||||
after,
|
||||
});
|
||||
|
||||
console.log(batch);
|
||||
if (!batch.length) break;
|
||||
|
||||
members.push(
|
||||
...batch.map((e) => ({
|
||||
username: e.user.username,
|
||||
display: e.nick ?? e.user.global_name ?? e.user.username,
|
||||
uid: e.user.id,
|
||||
})),
|
||||
);
|
||||
|
||||
// discord pagination cursor
|
||||
after = batch[batch.length - 1].user.id;
|
||||
|
||||
// stop if this was the final page
|
||||
if (batch.length < 1000) break;
|
||||
}
|
||||
|
||||
redis!.set("userlist", JSON.stringify(members));
|
||||
|
||||
return members;
|
||||
}
|
||||
}) as Promise<DiscordUser[]>,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, mock, test } from "bun:test";
|
||||
import { createDiscordUsersHandler } from "./handler";
|
||||
|
||||
test("Discord users route serializes the service result", async () => {
|
||||
const getUsers = mock(async () => [{ uid: "1", display: "User" }]);
|
||||
const response = await createDiscordUsersHandler(getUsers)();
|
||||
expect(await response.json()).toEqual([{ uid: "1", display: "User" }]);
|
||||
expect(getUsers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export function createDiscordUsersHandler(
|
||||
getDiscordUsers: () => Promise<unknown>,
|
||||
) {
|
||||
return async function GET() {
|
||||
return Response.json(await getDiscordUsers());
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getDiscordUsers } from "./api";
|
||||
import { createDiscordUsersHandler } from "./handler";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(await getDiscordUsers());
|
||||
}
|
||||
export const GET = createDiscordUsersHandler(getDiscordUsers);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import {
|
||||
createDiscordMemberAddHandler,
|
||||
createDiscordUsersService,
|
||||
type DiscordUser,
|
||||
} from "./service";
|
||||
|
||||
const member = (id: string, nick: string | null = null): DiscordUser => ({
|
||||
nick,
|
||||
user: { id, username: `user-${id}`, global_name: `global-${id}` },
|
||||
});
|
||||
|
||||
function dependencies() {
|
||||
return {
|
||||
adminCheck: mock(async () => true),
|
||||
forbidden: mock((): never => {
|
||||
throw new Error("forbidden");
|
||||
}),
|
||||
getCached: mock(async (): Promise<string | null> => null),
|
||||
setCached: mock(async (_value: string) => {}),
|
||||
getMembers: mock(async (_after: string): Promise<DiscordUser[]> => []),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Discord users service", () => {
|
||||
test("forbids non-admin callers before reading cache", async () => {
|
||||
const deps = dependencies();
|
||||
deps.adminCheck.mockResolvedValue(false);
|
||||
expect(createDiscordUsersService(deps)()).rejects.toThrow("forbidden");
|
||||
expect(deps.getCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns cached users without calling Discord", async () => {
|
||||
const deps = dependencies();
|
||||
deps.getCached.mockResolvedValue(
|
||||
'[{"uid":"1","username":"user","display":"User"}]',
|
||||
);
|
||||
expect(await createDiscordUsersService(deps)()).toEqual([
|
||||
{ uid: "1", username: "user", display: "User" },
|
||||
]);
|
||||
expect(deps.getMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("paginates, normalizes, and caches guild members", async () => {
|
||||
const deps = dependencies();
|
||||
const first = Array.from({ length: 1000 }, (_, index) =>
|
||||
member(String(index + 1)),
|
||||
);
|
||||
deps.getMembers
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce([member("1001", "Nick")]);
|
||||
const users = await createDiscordUsersService(deps)();
|
||||
expect(deps.getMembers).toHaveBeenNthCalledWith(1, "0");
|
||||
expect(deps.getMembers).toHaveBeenNthCalledWith(2, "1000");
|
||||
expect(users).toHaveLength(1001);
|
||||
expect(users.at(-1)).toEqual({
|
||||
uid: "1001",
|
||||
username: "user-1001",
|
||||
display: "Nick",
|
||||
});
|
||||
expect(JSON.parse(deps.setCached.mock.calls[0]![0])).toEqual(users);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Discord member-add cache", () => {
|
||||
test("adds a member not already present", async () => {
|
||||
const deps = dependencies();
|
||||
deps.getCached.mockResolvedValue(
|
||||
'[{"uid":"1","username":"old","display":"Old"}]',
|
||||
);
|
||||
await createDiscordMemberAddHandler(deps)(member("2"));
|
||||
expect(JSON.parse(deps.setCached.mock.calls[0]![0])).toEqual([
|
||||
{ uid: "1", username: "old", display: "Old" },
|
||||
{ uid: "2", username: "user-2", display: "global-2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not duplicate an existing member", async () => {
|
||||
const deps = dependencies();
|
||||
deps.getCached.mockResolvedValue('[{"uid":"2"}]');
|
||||
await createDiscordMemberAddHandler(deps)(member("2"));
|
||||
expect(deps.setCached).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
export type DiscordUser = {
|
||||
nick: string | null;
|
||||
user: { global_name: string | null; username: string; id: string };
|
||||
};
|
||||
|
||||
export type CachedDiscordUser = {
|
||||
username: string;
|
||||
uid: string;
|
||||
display: string;
|
||||
};
|
||||
|
||||
const normalize = (member: DiscordUser): CachedDiscordUser => ({
|
||||
username: member.user.username,
|
||||
display: member.nick ?? member.user.global_name ?? member.user.username,
|
||||
uid: member.user.id,
|
||||
});
|
||||
|
||||
type DiscordUsersDependencies = {
|
||||
adminCheck: () => Promise<unknown>;
|
||||
forbidden: () => never;
|
||||
getCached: () => Promise<string | null>;
|
||||
setCached: (value: string) => Promise<unknown> | unknown;
|
||||
getMembers: (after: string) => Promise<DiscordUser[]>;
|
||||
};
|
||||
|
||||
export function createDiscordUsersService(
|
||||
dependencies: DiscordUsersDependencies,
|
||||
) {
|
||||
return async function getDiscordUsers() {
|
||||
if (!(await dependencies.adminCheck())) dependencies.forbidden();
|
||||
|
||||
const cached = await dependencies.getCached();
|
||||
if (cached) return JSON.parse(cached) as CachedDiscordUser[];
|
||||
|
||||
const members: CachedDiscordUser[] = [];
|
||||
let after = "0";
|
||||
while (true) {
|
||||
const batch = await dependencies.getMembers(after);
|
||||
if (!batch.length) break;
|
||||
members.push(...batch.map(normalize));
|
||||
after = batch.at(-1)!.user.id;
|
||||
if (batch.length < 1000) break;
|
||||
}
|
||||
await dependencies.setCached(JSON.stringify(members));
|
||||
return members;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiscordMemberAddHandler(dependencies: {
|
||||
getCached: () => Promise<string | null>;
|
||||
setCached: (value: string) => Promise<unknown> | unknown;
|
||||
}) {
|
||||
return async function onMemberAdd(member: DiscordUser) {
|
||||
const cached: CachedDiscordUser[] = JSON.parse(
|
||||
(await dependencies.getCached()) ?? "[]",
|
||||
);
|
||||
if (!cached.some((user) => user.uid === member.user.id)) {
|
||||
cached.push(normalize(member));
|
||||
await dependencies.setCached(JSON.stringify(cached));
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user