From f9a35ee23035ad20845eb1d6dd97ec5f427c0fd1 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 9 Jul 2026 01:29:56 +0700 Subject: [PATCH] Fix booster role metadata updates --- src/services/boostCleanupService.test.ts | 9 +++- src/services/boosterRoleService.test.ts | 13 ++++- src/services/boosterRoleService.ts | 3 ++ src/services/drizzleBoosterRoleStore.test.ts | 53 ++++++++++++++++++++ src/services/drizzleBoosterRoleStore.ts | 15 ++++++ 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 src/services/drizzleBoosterRoleStore.test.ts diff --git a/src/services/boostCleanupService.test.ts b/src/services/boostCleanupService.test.ts index fcf5f17..bfe162c 100644 --- a/src/services/boostCleanupService.test.ts +++ b/src/services/boostCleanupService.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { startBoostCleanup } from "./boostCleanupService"; -import type { BoosterRoleRecord } from "./drizzleBoosterRoleStore"; +import type { BoosterRoleRecord, BoosterRoleUpdate } from "./drizzleBoosterRoleStore"; import type { RoleRepository } from "./discordRoleRepository"; class MemoryStore { @@ -18,6 +18,13 @@ class MemoryStore { this.records.set(`${record.guildId}:${record.userId}`, record); } + async update(guildId: string, userId: string, changes: BoosterRoleUpdate) { + const key = `${guildId}:${userId}`; + const record = this.records.get(key); + if (!record) return; + this.records.set(key, { ...record, ...changes }); + } + async delete(guildId: string, userId: string) { this.records.delete(`${guildId}:${userId}`); } diff --git a/src/services/boosterRoleService.test.ts b/src/services/boosterRoleService.test.ts index bda11bd..94184ea 100644 --- a/src/services/boosterRoleService.test.ts +++ b/src/services/boosterRoleService.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BoosterRoleService } from "./boosterRoleService"; -import type { BoosterRoleRecord } from "./drizzleBoosterRoleStore"; +import type { BoosterRoleRecord, BoosterRoleUpdate } from "./drizzleBoosterRoleStore"; import type { RoleRepository } from "./discordRoleRepository"; class MemoryRoleStore { @@ -18,6 +18,13 @@ class MemoryRoleStore { this.records.set(`${record.guildId}:${record.userId}`, record); } + async update(guildId: string, userId: string, changes: BoosterRoleUpdate): Promise { + const key = `${guildId}:${userId}`; + const record = this.records.get(key); + if (!record) return; + this.records.set(key, { ...record, ...changes }); + } + async delete(guildId: string, userId: string): Promise { this.records.delete(`${guildId}:${userId}`); } @@ -115,6 +122,7 @@ describe("BoosterRoleService", () => { await service.renameRole({ guildId: "guild", userId: "user", name: "Renamed" }); expect(roles.roles.get(claimed.roleId)?.name).toBe("Renamed"); + expect(await store.findByUser("guild", "user")).toEqual(expect.objectContaining({ name: "Renamed" })); await expect(service.renameRole({ guildId: "guild", userId: "attacker", name: "Stolen" })).rejects.toThrow("No booster role found"); }); @@ -127,6 +135,7 @@ describe("BoosterRoleService", () => { await service.setRoleIcon({ guildId: "guild", userId: "user", icon: { contentType: "image/png", size: 128_000, dataUri: "data:image/png;base64,abc" } }); expect(roles.roles.get(claimed.roleId)?.icon).toBe("data:image/png;base64,abc"); + expect(await store.findByUser("guild", "user")).toEqual(expect.objectContaining({ icon: "data:image/png;base64,abc" })); await expect(service.setRoleIcon({ guildId: "guild", userId: "attacker", icon: { contentType: "image/png", size: 128_000, dataUri: "data:image/png;base64,abc" } })).rejects.toThrow("No booster role found"); }); @@ -175,6 +184,7 @@ describe("BoosterRoleService", () => { const updated = roles.getRole(claimed.roleId); expect(updated).not.toBeNull(); expect(updated!.colors).toEqual({ primaryColor: "#FF0000" }); + expect(await store.findByUser("guild", "user")).toEqual(expect.objectContaining({ color: "#FF0000", color2: null })); // Other user cannot recolor await expect(service.recolorRole({ guildId: "guild", userId: "attacker", color: "#00FF00" })).rejects.toThrow("No booster role found"); @@ -191,6 +201,7 @@ describe("BoosterRoleService", () => { const updated = roles.getRole(claimed.roleId); expect(updated).not.toBeNull(); expect(updated!.colors).toEqual({ primaryColor: "#FF0000", secondaryColor: "#0000FF" }); + expect(await store.findByUser("guild", "user")).toEqual(expect.objectContaining({ color: "#FF0000", color2: "#0000FF" })); }); test("deletes the stored role owned by the user", async () => { diff --git a/src/services/boosterRoleService.ts b/src/services/boosterRoleService.ts index cdec001..0ea4d50 100644 --- a/src/services/boosterRoleService.ts +++ b/src/services/boosterRoleService.ts @@ -108,6 +108,7 @@ export class BoosterRoleService { const name = validateRoleName(input.name); assertRoleNameIsAvailable(name, (await this.roles.listRoles()).filter((role) => role.id !== record.roleId)); await this.roles.updateRole(record.roleId, { name }); + await this.store.update(guildId, userId, { name, updatedAt: this.now() }); } async recolorRole(input: { guildId: string; userId: string; color: string; color2?: string | null }): Promise { @@ -118,6 +119,7 @@ export class BoosterRoleService { const secondaryColor = normalizeOptionalHexColor(color2 ?? null); const colors = resolveGradientColors(primaryColor, secondaryColor); await this.roles.updateRole(record.roleId, { colors }); + await this.store.update(guildId, userId, { color: primaryColor, color2: secondaryColor, updatedAt: this.now() }); } async setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise { @@ -126,6 +128,7 @@ export class BoosterRoleService { assertCanManageStoredRole(this.identity(record), { guildId, userId, roleId: record.roleId }); this.validateRoleIcon(icon); await this.roles.updateRole(record.roleId, { icon: icon.dataUri }); + await this.store.update(guildId, userId, { icon: icon.dataUri, updatedAt: this.now() }); } async deleteRole(input: { guildId: string; userId: string }): Promise { diff --git a/src/services/drizzleBoosterRoleStore.test.ts b/src/services/drizzleBoosterRoleStore.test.ts new file mode 100644 index 0000000..a2e36a4 --- /dev/null +++ b/src/services/drizzleBoosterRoleStore.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { DrizzleBoosterRoleStore, type BoosterRoleRecord, type BoosterRoleUpdate } from "./drizzleBoosterRoleStore"; + +class FakeDb { + rows: BoosterRoleRecord[] = []; + lastUpdate: { changes: BoosterRoleUpdate; condition: unknown } | null = null; + + select() { + return { + from: () => ({ + where: () => Object.assign(Promise.resolve(this.rows), { + limit: (count: number) => Promise.resolve(this.rows.slice(0, count)) + }) + }) + }; + } + + insert() { + return { + values: (record: BoosterRoleRecord) => { + this.rows.push(record); + } + }; + } + + update() { + return { + set: (changes: BoosterRoleUpdate) => ({ + where: (condition: unknown) => { + this.lastUpdate = { changes, condition }; + } + }) + }; + } + + delete() { + return { + where: () => {} + }; + } +} + +describe("DrizzleBoosterRoleStore", () => { + test("persists metadata updates for a stored booster role", async () => { + const db = new FakeDb(); + const store = new DrizzleBoosterRoleStore(db); + + await store.update("guild", "user", { name: "Renamed", color: "#AABBCC", updatedAt: 123 }); + + expect(db.lastUpdate?.changes).toEqual({ name: "Renamed", color: "#AABBCC", updatedAt: 123 }); + expect(db.lastUpdate?.condition).toBeDefined(); + }); +}); diff --git a/src/services/drizzleBoosterRoleStore.ts b/src/services/drizzleBoosterRoleStore.ts index ea1a38a..2a06532 100644 --- a/src/services/drizzleBoosterRoleStore.ts +++ b/src/services/drizzleBoosterRoleStore.ts @@ -13,10 +13,13 @@ export type BoosterRoleRecord = { updatedAt: number; }; +export type BoosterRoleUpdate = Partial>; + export type BoosterRoleStore = { findByUser(guildId: string, userId: string): Promise; findByGuild(guildId: string): Promise; create(record: BoosterRoleRecord): Promise; + update(guildId: string, userId: string, changes: BoosterRoleUpdate): Promise; delete(guildId: string, userId: string): Promise; }; @@ -35,6 +38,11 @@ type DatabaseLike = { insert(table: typeof boosterRoles): { values(record: BoosterRoleRecord): Promise | unknown; }; + update(table: typeof boosterRoles): { + set(changes: BoosterRoleUpdate): { + where(condition: unknown): Promise | unknown; + }; + }; delete(table: typeof boosterRoles): { where(condition: unknown): Promise | unknown; }; @@ -64,6 +72,13 @@ export class DrizzleBoosterRoleStore implements BoosterRoleStore { await this.db.insert(boosterRoles).values(record); } + async update(guildId: string, userId: string, changes: BoosterRoleUpdate): Promise { + await this.db + .update(boosterRoles) + .set(changes) + .where(and(eq(boosterRoles.guildId, guildId), eq(boosterRoles.userId, userId))); + } + async delete(guildId: string, userId: string): Promise { await this.db .delete(boosterRoles)