feat: add extensive test suite

This commit is contained in:
2026-08-30 11:33:53 +07:00 Unverified
parent 061cce00dc
commit 8359c7272e
131 changed files with 8717 additions and 2200 deletions
+17 -58
View File
@@ -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[]>,
});
+9
View File
@@ -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);
});
+7
View File
@@ -0,0 +1,7 @@
export function createDiscordUsersHandler(
getDiscordUsers: () => Promise<unknown>,
) {
return async function GET() {
return Response.json(await getDiscordUsers());
};
}
+2 -3
View File
@@ -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);
+84
View File
@@ -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();
});
});
+62
View File
@@ -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));
}
};
}