feat: implement backlog sync cooldown mechanism and update related tests

This commit is contained in:
MythEclipse
2026-05-15 18:38:17 +07:00
parent a97feb1e2a
commit 61b07e4b01
3 changed files with 87 additions and 7 deletions

View File

@@ -125,13 +125,13 @@
el.channelSelect.value = state.selectedVoiceChannel; el.channelSelect.value = state.selectedVoiceChannel;
el.channelFilter.value = state.selectedTextChannel; el.channelFilter.value = state.selectedTextChannel;
applyActiveTab(state.activeTab); applyActiveTab(state.activeTab);
if (textChanged || textGuildChanged || state.activeTab === 'text') { if ((textChanged || textGuildChanged) && state.selectedTextChannel && state.selectedTextGuild) {
if (state.selectedTextChannel && state.selectedTextGuild) {
await apiRequest('/api/backlog-sync', { await apiRequest('/api/backlog-sync', {
method: 'POST', method: 'POST',
body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }), body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }),
}).catch((error) => showError(`Backlog sync failed: ${error.message}`)); }).catch((error) => showError(`Backlog sync failed: ${error.message}`));
} }
if (textChanged || textGuildChanged || state.activeTab === 'text') {
await fetchText().catch((error) => showError(error.message)); await fetchText().catch((error) => showError(error.message));
} }
await reconcileListenState(); await reconcileListenState();

View File

@@ -6,6 +6,24 @@ import { createChildLogger } from "../logger";
import { syncSelectedChannelBacklog } from "../moderation/backlogSync"; import { syncSelectedChannelBacklog } from "../moderation/backlogSync";
const logger = createChildLogger("sync-routes"); const logger = createChildLogger("sync-routes");
const BACKLOG_SYNC_COOLDOWN_MS = 5 * 60 * 1000;
const recentBacklogSyncs = new Map<string, number>();
export function shouldSkipRecentBacklogSync(
guildId: string,
channelId: string,
now = Date.now(),
): boolean {
const key = `${guildId}:${channelId}`;
const lastSync = recentBacklogSyncs.get(key);
if (lastSync && now - lastSync < BACKLOG_SYNC_COOLDOWN_MS) return true;
recentBacklogSyncs.set(key, now);
return false;
}
export function clearRecentBacklogSyncs(): void {
recentBacklogSyncs.clear();
}
export function createSyncRoutes(client: Client): Router { export function createSyncRoutes(client: Client): Router {
const router = express.Router(); const router = express.Router();
@@ -26,6 +44,17 @@ export function createSyncRoutes(client: Client): Router {
); );
} }
if (shouldSkipRecentBacklogSync(guildId, channelId)) {
logger.debug({ guildId, channelId }, "Skipping recent backlog sync");
res.json({
success: true,
channelId,
messagesSync: 0,
skipped: true,
});
return;
}
logger.info({ guildId, channelId }, "Starting backlog sync"); logger.info({ guildId, channelId }, "Starting backlog sync");
const count = await syncSelectedChannelBacklog( const count = await syncSelectedChannelBacklog(
@@ -43,6 +72,7 @@ export function createSyncRoutes(client: Client): Router {
success: true, success: true,
channelId, channelId,
messagesSync: count, messagesSync: count,
skipped: false,
}); });
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -1,6 +1,10 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createSyncRoutes } from "../../src/routes/syncRoutes"; import {
clearRecentBacklogSyncs,
createSyncRoutes,
shouldSkipRecentBacklogSync,
} from "../../src/routes/syncRoutes";
const syncSelectedChannelBacklog = vi.hoisted(() => vi.fn()); const syncSelectedChannelBacklog = vi.hoisted(() => vi.fn());
@@ -11,6 +15,7 @@ vi.mock("../../src/moderation/backlogSync", () => ({
describe("createSyncRoutes", () => { describe("createSyncRoutes", () => {
beforeEach(() => { beforeEach(() => {
syncSelectedChannelBacklog.mockReset(); syncSelectedChannelBacklog.mockReset();
clearRecentBacklogSyncs();
}); });
it("syncs the selected guild and channel from the request", async () => { it("syncs the selected guild and channel from the request", async () => {
@@ -41,7 +46,52 @@ describe("createSyncRoutes", () => {
success: true, success: true,
channelId: "selected-channel", channelId: "selected-channel",
messagesSync: 3, messagesSync: 3,
skipped: false,
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
it("skips repeated sync requests during the cooldown window", async () => {
expect(shouldSkipRecentBacklogSync("guild", "channel", 1000)).toBe(false);
expect(shouldSkipRecentBacklogSync("guild", "channel", 1001)).toBe(true);
});
it("allows repeated sync requests after the cooldown window", async () => {
expect(shouldSkipRecentBacklogSync("guild", "channel", 1000)).toBe(false);
expect(shouldSkipRecentBacklogSync("guild", "channel", 301001)).toBe(false);
});
it("does not call Discord backlog sync for repeated requests", async () => {
syncSelectedChannelBacklog.mockResolvedValue(3);
const router = createSyncRoutes({} as never);
const route = router.stack.find(
(layer) => layer.route?.path === "/backlog-sync",
);
const handler = route?.route?.stack[0]?.handle;
await handler?.(
{
body: { guildId: "selected-guild", channelId: "selected-channel" },
} as Request,
{ json: vi.fn() } as unknown as Response,
vi.fn(),
);
const json = vi.fn();
await handler?.(
{
body: { guildId: "selected-guild", channelId: "selected-channel" },
} as Request,
{ json } as unknown as Response,
vi.fn(),
);
expect(syncSelectedChannelBacklog).toHaveBeenCalledTimes(1);
expect(json).toHaveBeenCalledWith({
success: true,
channelId: "selected-channel",
messagesSync: 0,
skipped: true,
});
});
}); });