feat: update booster role eligibility logic and refactor related services

This commit is contained in:
MythEclipse
2026-05-16 00:06:13 +07:00
parent 972400e4e4
commit acd4648d49
16 changed files with 478 additions and 40 deletions
+8 -8
View File
@@ -55,7 +55,7 @@ describe("BoosterRoleService", () => {
const roles = new FakeRoleRepository();
const service = new BoosterRoleService(store, roles, { anchorPosition: 10 });
const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "My Role", color: "#aabbcc", verifiedBoostCount: 2 });
const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "My Role", color: "#aabbcc", isBoosting: true });
expect(claimed.roleId).toBe("created-2");
expect(roles.roles.get(claimed.roleId)?.permissions).toEqual([]);
@@ -66,7 +66,7 @@ describe("BoosterRoleService", () => {
test("rejects claiming an existing unmanaged role name", async () => {
const service = new BoosterRoleService(new MemoryRoleStore(), new FakeRoleRepository(), { anchorPosition: 10 });
await expect(service.claimRole({ guildId: "guild", userId: "user", name: "vip", color: null, verifiedBoostCount: 2 })).rejects.toThrow("already used");
await expect(service.claimRole({ guildId: "guild", userId: "user", name: "vip", color: null, isBoosting: true })).rejects.toThrow("already used");
});
test("rejects duplicate claims instead of creating another role", async () => {
@@ -74,16 +74,16 @@ describe("BoosterRoleService", () => {
const roles = new FakeRoleRepository();
const service = new BoosterRoleService(store, roles, { anchorPosition: 10 });
await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, verifiedBoostCount: 2 });
await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true });
await expect(service.claimRole({ guildId: "guild", userId: "user", name: "Second Role", color: null, verifiedBoostCount: 2 })).rejects.toThrow("already has a booster role");
await expect(service.claimRole({ guildId: "guild", userId: "user", name: "Second Role", color: null, isBoosting: true })).rejects.toThrow("already has a booster role");
});
test("renames 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, verifiedBoostCount: 2 });
const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true });
await service.renameRole({ guildId: "guild", userId: "user", name: "Renamed" });
@@ -95,7 +95,7 @@ describe("BoosterRoleService", () => {
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, verifiedBoostCount: 2 });
const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true });
await service.setRoleIcon({ guildId: "guild", userId: "user", icon: { contentType: "image/png", size: 128_000, dataUri: "data:image/png;base64,abc" } });
@@ -107,7 +107,7 @@ describe("BoosterRoleService", () => {
const store = new MemoryRoleStore();
const roles = new FakeRoleRepository([]);
const service = new BoosterRoleService(store, roles, { anchorPosition: 10, maxIconBytes: 256_000 });
await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, verifiedBoostCount: 2 });
await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true });
await expect(service.setRoleIcon({ guildId: "guild", userId: "user", icon: { contentType: "text/html", size: 100, dataUri: "data:text/html;base64,abc" } })).rejects.toThrow("Role icon must be an image");
await expect(service.setRoleIcon({ guildId: "guild", userId: "user", icon: { contentType: "image/png", size: 256_001, dataUri: "data:image/png;base64,abc" } })).rejects.toThrow("Role icon is too large");
@@ -117,7 +117,7 @@ describe("BoosterRoleService", () => {
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, verifiedBoostCount: 2 });
const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true });
await service.removeRoleForLostBoost({ guildId: "guild", userId: "user" });
+11 -3
View File
@@ -13,6 +13,7 @@ export type BoosterRoleRecord = {
roleId: string;
name: string;
color: string | null;
icon: string | null;
createdAt: number;
updatedAt: number;
};
@@ -58,10 +59,11 @@ export class BoosterRoleService {
userId: string;
name: string;
color: string | null;
verifiedBoostCount: number | null;
icon?: RoleIcon | null;
isBoosting: boolean;
}): Promise<BoosterRoleRecord> {
const { guildId, userId, verifiedBoostCount } = input;
assertBoostEligibility({ verifiedBoostCount });
const { guildId, userId, isBoosting } = input;
assertBoostEligibility({ isBoosting });
const existingRecord = await this.store.findByUser(guildId, userId);
if (existingRecord) {
@@ -76,6 +78,11 @@ export class BoosterRoleService {
assertRolePositionIsSafe(position, this.options.anchorPosition);
const role = await this.roles.createRole({ name, color, permissions: [], position });
if (input.icon) {
this.validateRoleIcon(input.icon);
await this.roles.updateRole(role.id, { icon: input.icon.dataUri });
}
const timestamp = this.now();
const record = {
guildId,
@@ -83,6 +90,7 @@ export class BoosterRoleService {
roleId: role.id,
name,
color,
icon: input.icon?.dataUri ?? null,
createdAt: timestamp,
updatedAt: timestamp
};
@@ -0,0 +1,26 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { BunSqliteBoosterRoleStore } from "./bunSqliteBoosterRoleStore";
describe("BunSqliteBoosterRoleStore", () => {
test("creates schema and stores booster role records", async () => {
const db = new Database(":memory:");
const store = new BunSqliteBoosterRoleStore(db);
const record = {
guildId: "guild",
userId: "user",
roleId: "role",
name: "Test Role",
color: "#AABBCC",
icon: null,
createdAt: 1,
updatedAt: 1
};
await store.create(record);
expect(await store.findByUser("guild", "user")).toEqual(record);
await store.delete("guild", "user");
expect(await store.findByUser("guild", "user")).toBeNull();
});
});
+71
View File
@@ -0,0 +1,71 @@
import type { Database } from "bun:sqlite";
import type { BoosterRoleRecord, BoosterRoleStore } from "./boosterRoleService";
export class BunSqliteBoosterRoleStore implements BoosterRoleStore {
constructor(private readonly db: Database) {
this.db.run(`
CREATE TABLE IF NOT EXISTS booster_roles (
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(guild_id, user_id),
UNIQUE(guild_id, role_id)
)
`);
}
async findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null> {
const row = this.db
.query<BoosterRoleRow, [string, string]>(`
SELECT guild_id, user_id, role_id, name, color, icon, created_at, updated_at
FROM booster_roles
WHERE guild_id = ? AND user_id = ?
LIMIT 1
`)
.get(guildId, userId);
return row ? toRecord(row) : null;
}
async create(record: BoosterRoleRecord): Promise<void> {
this.db
.query<unknown, [string, string, string, string, string | null, string | null, number, number]>(`
INSERT INTO booster_roles (guild_id, user_id, role_id, name, color, icon, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
.run(record.guildId, record.userId, record.roleId, record.name, record.color, record.icon, record.createdAt, record.updatedAt);
}
async delete(guildId: string, userId: string): Promise<void> {
this.db.query<unknown, [string, string]>("DELETE FROM booster_roles WHERE guild_id = ? AND user_id = ?").run(guildId, userId);
}
}
type BoosterRoleRow = {
guild_id: string;
user_id: string;
role_id: string;
name: string;
color: string | null;
icon: string | null;
created_at: number;
updated_at: number;
};
function toRecord(row: BoosterRoleRow): BoosterRoleRecord {
return {
guildId: row.guild_id,
userId: row.user_id,
roleId: row.role_id,
name: row.name,
color: row.color,
icon: row.icon,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
+53
View File
@@ -0,0 +1,53 @@
import type { ColorResolvable, Guild, Role } from "discord.js";
import type { RoleRepository } from "./boosterRoleService";
export class DiscordRoleRepository implements RoleRepository {
constructor(private readonly guild: Guild, private readonly anchorRoleId: string | null) {}
async listRoles() {
await this.guild.roles.fetch();
return this.guild.roles.cache.map((role) => ({ id: role.id, name: role.name }));
}
async createRole(input: { name: string; color: string | null; permissions: string[]; position: number }): Promise<{ id: string }> {
const role = await this.guild.roles.create({
name: input.name,
color: toDiscordColor(input.color),
permissions: 0n
});
await role.setPosition(await this.resolvePosition(input.position));
return { id: role.id };
}
async updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }): Promise<void> {
const role = await this.fetchRole(roleId);
await role.edit({
name: input.name,
color: input.color === undefined ? undefined : toDiscordColor(input.color),
icon: input.icon === undefined ? undefined : input.icon
});
}
async deleteRole(roleId: string): Promise<void> {
const role = await this.fetchRole(roleId);
await role.delete();
}
private async resolvePosition(fallbackPosition: number): Promise<number> {
if (!this.anchorRoleId) return fallbackPosition;
const anchor = await this.fetchRole(this.anchorRoleId);
return Math.max(anchor.position - 1, 1);
}
private async fetchRole(roleId: string): Promise<Role> {
const role = await this.guild.roles.fetch(roleId);
if (!role) throw new Error("Discord role not found");
return role;
}
}
function toDiscordColor(color: string | null): ColorResolvable | undefined {
return color === null ? undefined : (color as ColorResolvable);
}
+48
View File
@@ -0,0 +1,48 @@
import { and, eq } from "drizzle-orm";
import { boosterRoles } from "../db/schema";
import type { BoosterRoleRecord, BoosterRoleStore } from "./boosterRoleService";
type DatabaseLike = {
select(): {
from(table: typeof boosterRoles): {
where(condition: unknown): {
limit(count: number): Promise<BoosterRoleRecord[]> | BoosterRoleRecord[];
};
};
};
insert(table: typeof boosterRoles): {
values(record: BoosterRoleRecord): {
run(): unknown;
};
};
delete(table: typeof boosterRoles): {
where(condition: unknown): {
run(): unknown;
};
};
};
export class SqliteBoosterRoleStore implements BoosterRoleStore {
constructor(private readonly db: DatabaseLike) {}
async findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null> {
const rows = await this.db
.select()
.from(boosterRoles)
.where(and(eq(boosterRoles.guildId, guildId), eq(boosterRoles.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async create(record: BoosterRoleRecord): Promise<void> {
await this.db.insert(boosterRoles).values(record).run();
}
async delete(guildId: string, userId: string): Promise<void> {
await this.db
.delete(boosterRoles)
.where(and(eq(boosterRoles.guildId, guildId), eq(boosterRoles.userId, userId)))
.run();
}
}