test: add boost cleanup service tests and expand booster role test coverage

This commit is contained in:
MythEclipse
2026-06-06 03:11:59 +07:00
parent a796034241
commit 0f6d9110a3
3 changed files with 234 additions and 2 deletions
+5
View File
@@ -141,11 +141,13 @@ export async function handleInteraction(
}
function toUserErrorMessage(error: unknown): string {
// Custom domain errors are safe to show directly
if (error instanceof ValidationError || error instanceof NotFoundError || error instanceof PermissionError) {
return error.message;
}
if (error instanceof Error) {
// Hide infrastructure/internal details
if (error.message.includes("Missing Permissions")) {
return "Bot is missing permissions or role position to manage this role.";
}
@@ -153,6 +155,9 @@ function toUserErrorMessage(error: unknown): string {
if (error.message.includes("Failed query") || error.message.includes("insert")) {
return "Failed to save booster role. Any created role was cleaned up. Try again.";
}
// Generic domain errors and validations are safe to show
return error.message;
}
return "Command failed";
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, test } from "bun:test";
import { startBoostCleanup } from "./boostCleanupService";
import type { BoosterRoleRecord } from "./drizzleBoosterRoleStore";
import type { RoleRepository } from "./discordRoleRepository";
class MemoryStore {
private records = new Map<string, BoosterRoleRecord>();
async findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null> {
return this.records.get(`${guildId}:${userId}`) ?? null;
}
async findByGuild(guildId: string): Promise<BoosterRoleRecord[]> {
return Array.from(this.records.values()).filter((r) => r.guildId === guildId);
}
async create(record: BoosterRoleRecord) {
this.records.set(`${record.guildId}:${record.userId}`, record);
}
async delete(guildId: string, userId: string) {
this.records.delete(`${guildId}:${userId}`);
}
}
class SpyRoleRepository implements RoleRepository {
listRolesCalls = 0;
deleteRoleCalls: string[] = [];
assignRoleCalls: Array<{ userId: string; roleId: string }> = [];
removeRoleCalls: Array<{ userId: string; roleId: string }> = [];
updateRoleCalls: Array<{ roleId: string; input: Record<string, unknown> }> = [];
async listRoles() {
this.listRolesCalls++;
return [];
}
async createRole() {
return { id: "spy-created-1" };
}
async updateRole(roleId: string, input: Record<string, unknown>) {
this.updateRoleCalls.push({ roleId, input });
}
async assignRole(userId: string, roleId: string) {
this.assignRoleCalls.push({ userId, roleId });
}
async removeRole(userId: string, roleId: string) {
this.removeRoleCalls.push({ userId, roleId });
}
async deleteRole(roleId: string) {
this.deleteRoleCalls.push(roleId);
}
}
class FakeClient {
guilds = {
cache: new Map<string, FakeGuild>()
};
}
class FakeGuild {
id: string;
members: { cache: Map<string, FakeMember>; fetch: () => Promise<void> };
constructor(id: string, members: FakeMember[] = []) {
this.id = id;
this.members = {
cache: new Map(members.map((m) => [m.id, m])),
fetch: async () => {},
};
}
}
class FakeMember {
id: string;
roles: { cache: Map<string, { id: string }> };
constructor(id: string, roleIds: string[] = []) {
this.id = id;
this.roles = { cache: new Map(roleIds.map((rid) => [rid, { id: rid }])) };
}
}
describe("startBoostCleanup", () => {
test("cleans up records for members who left the guild", async () => {
const store = new MemoryStore();
const roles = new SpyRoleRepository();
const client = new FakeClient() as any;
const guild = new FakeGuild("guild-1");
client.guilds.cache.set("guild-1", guild);
await store.create({ guildId: "guild-1", userId: "left-user", roleId: "role-left", name: "Left Role", color: null, color2: null, icon: null, createdAt: 1, updatedAt: 1 });
// Trigger one cleanup cycle
const { stop } = startBoostCleanup(client, store, () => roles, {
intervalMs: 60_000,
boosterEligibilityRoleId: "booster-role-id",
anchorRoleId: null,
});
// Wait a tick for async runCleanup
await new Promise(r => setTimeout(r, 10));
stop();
expect(roles.deleteRoleCalls).toEqual(["role-left"]);
expect(await store.findByUser("guild-1", "left-user")).toBeNull();
});
test("cleans up records for members who lost booster role", async () => {
const store = new MemoryStore();
const roles = new SpyRoleRepository();
const client = new FakeClient() as any;
const member = new FakeMember("stale-user", ["some-other-role"]);
const guild = new FakeGuild("guild-1", [member]);
client.guilds.cache.set("guild-1", guild);
await store.create({ guildId: "guild-1", userId: "stale-user", roleId: "role-stale", name: "Stale Role", color: null, color2: null, icon: null, createdAt: 1, updatedAt: 1 });
const { stop } = startBoostCleanup(client, store, () => roles, {
intervalMs: 60_000,
boosterEligibilityRoleId: "booster-role-id",
anchorRoleId: null,
});
await new Promise(r => setTimeout(r, 10));
stop();
expect(roles.deleteRoleCalls).toEqual(["role-stale"]);
expect(await store.findByUser("guild-1", "stale-user")).toBeNull();
});
test("keeps records for members who still have booster role", async () => {
const store = new MemoryStore();
const roles = new SpyRoleRepository();
const client = new FakeClient() as any;
const member = new FakeMember("active-user", ["booster-role-id"]);
const guild = new FakeGuild("guild-1", [member]);
client.guilds.cache.set("guild-1", guild);
await store.create({ guildId: "guild-1", userId: "active-user", roleId: "role-active", name: "Active Role", color: null, color2: null, icon: null, createdAt: 1, updatedAt: 1 });
const { stop } = startBoostCleanup(client, store, () => roles, {
intervalMs: 60_000,
boosterEligibilityRoleId: "booster-role-id",
anchorRoleId: null,
});
await new Promise(r => setTimeout(r, 10));
stop();
expect(roles.deleteRoleCalls).toEqual([]);
expect(await store.findByUser("guild-1", "active-user")).not.toBeNull();
});
test("handles guild without records gracefully", async () => {
const store = new MemoryStore();
const roles = new SpyRoleRepository();
const client = new FakeClient() as any;
const guild = new FakeGuild("guild-empty");
client.guilds.cache.set("guild-empty", guild);
const { stop } = startBoostCleanup(client, store, () => roles, {
intervalMs: 60_000,
boosterEligibilityRoleId: "booster-role-id",
anchorRoleId: null,
});
await new Promise(r => setTimeout(r, 10));
stop();
expect(roles.deleteRoleCalls).toEqual([]);
});
});
+56 -2
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test";
import { BoosterRoleService } from "./boosterRoleService";
import type { BoosterRoleRecord } from "./drizzleBoosterRoleStore";
import type { RoleRepository } from "./discordRoleRepository";
import { ValidationError, NotFoundError, PermissionError } from "../domain/errors";
class MemoryRoleStore {
private records = new Map<string, BoosterRoleRecord>();
@@ -48,7 +47,7 @@ class FakeRoleRepository implements RoleRepository {
async createRole(input: { name: string; color: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; permissions: string[]; position: number }) {
const id = `created-${this.roles.size + 1}`;
this.roles.set(id, { id, ...input });
this.roles.set(id, { id, name: input.name, color: input.color, colors: input.colors, permissions: input.permissions, position: input.position });
return { id };
}
@@ -58,6 +57,10 @@ class FakeRoleRepository implements RoleRepository {
this.roles.set(roleId, { ...role, ...input });
}
getRole(roleId: string) {
return this.roles.get(roleId) ?? null;
}
async assignRole(userId: string, roleId: string) {
this.assignedRoles.push({ userId, roleId });
}
@@ -160,4 +163,55 @@ describe("BoosterRoleService", () => {
expect(roles.deletedRoleIds).toEqual(["created-1"]);
expect(roles.roles.has("created-1")).toBe(false);
});
test("recolors only the stored role owned by the user", async () => {
const store = new MemoryRoleStore();
const roles = new FakeRoleRepository([]);
const service = new BoosterRoleService(store, roles, { anchorPosition: 10 });
const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true });
await service.recolorRole({ guildId: "guild", userId: "user", color: "#FF0000" });
const updated = roles.getRole(claimed.roleId);
expect(updated).not.toBeNull();
expect(updated!.colors).toEqual({ primaryColor: "#FF0000" });
// Other user cannot recolor
await expect(service.recolorRole({ guildId: "guild", userId: "attacker", color: "#00FF00" })).rejects.toThrow("No booster role found");
});
test("recolors role with gradient colors (primary + secondary)", async () => {
const store = new MemoryRoleStore();
const roles = new FakeRoleRepository([]);
const service = new BoosterRoleService(store, roles, { anchorPosition: 10 });
const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true });
await service.recolorRole({ guildId: "guild", userId: "user", color: "#FF0000", color2: "#0000FF" });
const updated = roles.getRole(claimed.roleId);
expect(updated).not.toBeNull();
expect(updated!.colors).toEqual({ primaryColor: "#FF0000", secondaryColor: "#0000FF" });
});
test("deletes the stored role owned by the user", async () => {
const store = new MemoryRoleStore();
const roles = new FakeRoleRepository([]);
const service = new BoosterRoleService(store, roles, { anchorPosition: 10 });
const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true });
await service.deleteRole({ guildId: "guild", userId: "user" });
expect(roles.deletedRoleIds).toEqual([claimed.roleId]);
expect(roles.roles.has(claimed.roleId)).toBe(false);
expect(await store.findByUser("guild", "user")).toBeNull();
});
test("rejects delete by non-owner user", async () => {
const store = new MemoryRoleStore();
const roles = new FakeRoleRepository([]);
const service = new BoosterRoleService(store, roles, { anchorPosition: 10 });
await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true });
await expect(service.deleteRole({ guildId: "guild", userId: "attacker" })).rejects.toThrow("No booster role found");
});
});