diff --git a/src/discord/interactionHandler.ts b/src/discord/interactionHandler.ts index e9af7f9..5a45e40 100644 --- a/src/discord/interactionHandler.ts +++ b/src/discord/interactionHandler.ts @@ -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"; diff --git a/src/services/boostCleanupService.test.ts b/src/services/boostCleanupService.test.ts new file mode 100644 index 0000000..fcf5f17 --- /dev/null +++ b/src/services/boostCleanupService.test.ts @@ -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(); + + async findByUser(guildId: string, userId: string): Promise { + return this.records.get(`${guildId}:${userId}`) ?? null; + } + + async findByGuild(guildId: string): Promise { + 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 }> = []; + + async listRoles() { + this.listRolesCalls++; + return []; + } + + async createRole() { + return { id: "spy-created-1" }; + } + + async updateRole(roleId: string, input: Record) { + 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() + }; +} + +class FakeGuild { + id: string; + members: { cache: Map; fetch: () => Promise }; + + 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 }; + + 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([]); + }); +}); diff --git a/src/services/boosterRoleService.test.ts b/src/services/boosterRoleService.test.ts index 464b109..bda11bd 100644 --- a/src/services/boosterRoleService.test.ts +++ b/src/services/boosterRoleService.test.ts @@ -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(); @@ -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"); + }); });