test: add boost cleanup service tests and expand booster role test coverage
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user