refactor: initial moves
This commit is contained in:
114
shared/modules/system/temp-role.service.test.ts
Normal file
114
shared/modules/system/temp-role.service.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||
import { userTimers } from "@db/schema";
|
||||
import { TimerType } from "@shared/lib/constants";
|
||||
|
||||
const mockDelete = mock();
|
||||
const mockWhere = mock();
|
||||
const mockFindMany = mock();
|
||||
|
||||
mockDelete.mockReturnValue({ where: mockWhere });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
delete: mockDelete,
|
||||
query: {
|
||||
userTimers: {
|
||||
findMany: mockFindMany
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock AuroraClient
|
||||
const mockRemoveRole = mock();
|
||||
const mockFetchMember = mock();
|
||||
const mockFetchGuild = mock();
|
||||
|
||||
mock.module("@/lib/BotClient", () => ({
|
||||
AuroraClient: {
|
||||
guilds: {
|
||||
fetch: mockFetchGuild
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("@shared/lib/env", () => ({
|
||||
env: {
|
||||
DISCORD_GUILD_ID: "guild123"
|
||||
}
|
||||
}));
|
||||
|
||||
describe("temporaryRoleService", () => {
|
||||
beforeEach(() => {
|
||||
mockDelete.mockClear();
|
||||
mockWhere.mockClear();
|
||||
mockFindMany.mockClear();
|
||||
mockRemoveRole.mockClear();
|
||||
mockFetchMember.mockClear();
|
||||
mockFetchGuild.mockClear();
|
||||
|
||||
mockFetchGuild.mockResolvedValue({
|
||||
members: {
|
||||
fetch: mockFetchMember
|
||||
}
|
||||
});
|
||||
|
||||
mockFetchMember.mockResolvedValue({
|
||||
user: { tag: "TestUser#1234" },
|
||||
roles: { remove: mockRemoveRole }
|
||||
});
|
||||
});
|
||||
|
||||
it("should revoke expired roles and delete timers", async () => {
|
||||
// Mock findMany to return an expired role timer
|
||||
mockFindMany.mockResolvedValue([
|
||||
{
|
||||
userId: 123n,
|
||||
type: TimerType.ACCESS,
|
||||
key: 'role_456',
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
metadata: { roleId: '456' }
|
||||
}
|
||||
]);
|
||||
|
||||
const count = await temporaryRoleService.processExpiredRoles();
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(mockFetchGuild).toHaveBeenCalledWith("guild123");
|
||||
expect(mockFetchMember).toHaveBeenCalledWith("123");
|
||||
expect(mockRemoveRole).toHaveBeenCalledWith("456");
|
||||
expect(mockDelete).toHaveBeenCalledWith(userTimers);
|
||||
});
|
||||
|
||||
it("should still delete the timer even if member is not found", async () => {
|
||||
mockFindMany.mockResolvedValue([
|
||||
{
|
||||
userId: 999n,
|
||||
type: TimerType.ACCESS,
|
||||
key: 'role_789',
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
metadata: {}
|
||||
}
|
||||
]);
|
||||
|
||||
// Mock member fetch failure
|
||||
mockFetchMember.mockRejectedValue(new Error("Member not found"));
|
||||
|
||||
const count = await temporaryRoleService.processExpiredRoles();
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(mockRemoveRole).not.toHaveBeenCalled();
|
||||
expect(mockDelete).toHaveBeenCalledWith(userTimers);
|
||||
});
|
||||
|
||||
it("should return 0 if no expired timers exist", async () => {
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
|
||||
const count = await temporaryRoleService.processExpiredRoles();
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
67
shared/modules/system/temp-role.service.ts
Normal file
67
shared/modules/system/temp-role.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { userTimers } from "@db/schema";
|
||||
import { eq, and, lt } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@shared/lib/env";
|
||||
import { TimerType } from "@shared/lib/constants";
|
||||
|
||||
export const temporaryRoleService = {
|
||||
/**
|
||||
* Checks for and revokes expired temporary roles.
|
||||
* This is intended to run as a high-frequency maintenance task.
|
||||
*/
|
||||
processExpiredRoles: async (): Promise<number> => {
|
||||
const now = new Date();
|
||||
let revokedCount = 0;
|
||||
|
||||
// Find all expired ACCESS (temporary role) timers
|
||||
const expiredTimers = await DrizzleClient.query.userTimers.findMany({
|
||||
where: and(
|
||||
eq(userTimers.type, TimerType.ACCESS),
|
||||
lt(userTimers.expiresAt, now)
|
||||
)
|
||||
});
|
||||
|
||||
if (expiredTimers.length === 0) return 0;
|
||||
|
||||
for (const timer of expiredTimers) {
|
||||
const userIdStr = timer.userId.toString();
|
||||
const meta = timer.metadata as any;
|
||||
|
||||
// We only handle keys that indicate role management
|
||||
if (timer.key.startsWith('role_')) {
|
||||
try {
|
||||
const roleId = meta?.roleId || timer.key.replace('role_', '');
|
||||
const guildId = env.DISCORD_GUILD_ID;
|
||||
|
||||
if (guildId) {
|
||||
const guild = await AuroraClient.guilds.fetch(guildId);
|
||||
const member = await guild.members.fetch(userIdStr).catch(() => null);
|
||||
|
||||
if (member) {
|
||||
await member.roles.remove(roleId);
|
||||
console.log(`👋 Temporary role ${roleId} revoked from ${member.user.tag} (Expired)`);
|
||||
} else {
|
||||
console.log(`⚠️ Could not find member ${userIdStr} to revoke role ${roleId}.`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed to revoke role for user ${userIdStr}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Always delete the timer record after trying to revoke (or if it's not a role key)
|
||||
// to prevent repeated failed attempts.
|
||||
await DrizzleClient.delete(userTimers)
|
||||
.where(and(
|
||||
eq(userTimers.userId, timer.userId),
|
||||
eq(userTimers.type, timer.type),
|
||||
eq(userTimers.key, timer.key)
|
||||
));
|
||||
|
||||
revokedCount++;
|
||||
}
|
||||
|
||||
return revokedCount;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user