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 {
|
function toUserErrorMessage(error: unknown): string {
|
||||||
|
// Custom domain errors are safe to show directly
|
||||||
if (error instanceof ValidationError || error instanceof NotFoundError || error instanceof PermissionError) {
|
if (error instanceof ValidationError || error instanceof NotFoundError || error instanceof PermissionError) {
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
// Hide infrastructure/internal details
|
||||||
if (error.message.includes("Missing Permissions")) {
|
if (error.message.includes("Missing Permissions")) {
|
||||||
return "Bot is missing permissions or role position to manage this role.";
|
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")) {
|
if (error.message.includes("Failed query") || error.message.includes("insert")) {
|
||||||
return "Failed to save booster role. Any created role was cleaned up. Try again.";
|
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";
|
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 { BoosterRoleService } from "./boosterRoleService";
|
||||||
import type { BoosterRoleRecord } from "./drizzleBoosterRoleStore";
|
import type { BoosterRoleRecord } from "./drizzleBoosterRoleStore";
|
||||||
import type { RoleRepository } from "./discordRoleRepository";
|
import type { RoleRepository } from "./discordRoleRepository";
|
||||||
import { ValidationError, NotFoundError, PermissionError } from "../domain/errors";
|
|
||||||
|
|
||||||
class MemoryRoleStore {
|
class MemoryRoleStore {
|
||||||
private records = new Map<string, BoosterRoleRecord>();
|
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 }) {
|
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}`;
|
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 };
|
return { id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +57,10 @@ class FakeRoleRepository implements RoleRepository {
|
|||||||
this.roles.set(roleId, { ...role, ...input });
|
this.roles.set(roleId, { ...role, ...input });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRole(roleId: string) {
|
||||||
|
return this.roles.get(roleId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async assignRole(userId: string, roleId: string) {
|
async assignRole(userId: string, roleId: string) {
|
||||||
this.assignedRoles.push({ userId, roleId });
|
this.assignedRoles.push({ userId, roleId });
|
||||||
}
|
}
|
||||||
@@ -160,4 +163,55 @@ describe("BoosterRoleService", () => {
|
|||||||
expect(roles.deletedRoleIds).toEqual(["created-1"]);
|
expect(roles.deletedRoleIds).toEqual(["created-1"]);
|
||||||
expect(roles.roles.has("created-1")).toBe(false);
|
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