2026-05-15 07:13:37 +07:00
|
|
|
import type { Mock } from "vitest";
|
2026-05-14 18:40:34 +07:00
|
|
|
import { describe, expect, it, vi } from "vitest";
|
2026-05-15 07:13:37 +07:00
|
|
|
import {
|
|
|
|
|
type BroadcasterClient,
|
|
|
|
|
createBroadcaster,
|
|
|
|
|
} from "../../src/moderation/broadcaster";
|
|
|
|
|
import type {
|
|
|
|
|
AttachmentRecord,
|
|
|
|
|
MessageRecord,
|
|
|
|
|
} from "../../src/moderation/types";
|
|
|
|
|
|
|
|
|
|
type TestClient = BroadcasterClient & { send: Mock };
|
|
|
|
|
|
|
|
|
|
function client(): TestClient {
|
2026-05-14 18:40:34 +07:00
|
|
|
return { readyState: 1, send: vi.fn() };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
function messageRecord(overrides: Partial<MessageRecord> = {}): MessageRecord {
|
|
|
|
|
return {
|
|
|
|
|
id: "m1",
|
|
|
|
|
guild_id: "guild-1",
|
|
|
|
|
channel_id: "channel-1",
|
|
|
|
|
thread_id: null,
|
|
|
|
|
user_id: "user-1",
|
|
|
|
|
username: "alice",
|
|
|
|
|
avatar_url: null,
|
|
|
|
|
content: "test",
|
|
|
|
|
edited_content: null,
|
|
|
|
|
created_at: 1,
|
|
|
|
|
edited_at: null,
|
|
|
|
|
deleted_at: null,
|
|
|
|
|
type: "text",
|
|
|
|
|
metadata: null,
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function attachmentRecord(
|
|
|
|
|
overrides: Partial<AttachmentRecord> = {},
|
|
|
|
|
): AttachmentRecord {
|
|
|
|
|
return {
|
|
|
|
|
id: "a1",
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
guild_id: "guild-1",
|
|
|
|
|
channel_id: "channel-1",
|
|
|
|
|
thread_id: null,
|
|
|
|
|
user_id: "user-1",
|
|
|
|
|
filename: "image.png",
|
|
|
|
|
size: 1,
|
|
|
|
|
type: "image/png",
|
|
|
|
|
discord_url: "https://example.com/image.png",
|
|
|
|
|
uploaded_url: null,
|
|
|
|
|
upload_status: "pending",
|
|
|
|
|
upload_error: null,
|
|
|
|
|
created_at: 1,
|
|
|
|
|
uploaded_at: null,
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 18:40:34 +07:00
|
|
|
describe("createBroadcaster", () => {
|
|
|
|
|
it("sends JSON events to open clients", () => {
|
|
|
|
|
const ws = client();
|
|
|
|
|
const broadcaster = createBroadcaster();
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
broadcaster.addClient(ws);
|
|
|
|
|
broadcaster.messageAnalyzed(messageRecord({ ai_status: "clean" }));
|
2026-05-14 18:40:34 +07:00
|
|
|
|
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(JSON.parse(ws.send.mock.calls[0][0])).toMatchObject({
|
|
|
|
|
type: "message_analyzed",
|
|
|
|
|
data: { id: "m1", ai_status: "clean" },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("skips closed clients", () => {
|
2026-05-15 07:13:37 +07:00
|
|
|
const ws: TestClient = { readyState: 3, send: vi.fn() };
|
2026-05-14 18:40:34 +07:00
|
|
|
const broadcaster = createBroadcaster();
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
broadcaster.addClient(ws);
|
2026-05-14 18:40:34 +07:00
|
|
|
broadcaster.messageDeleted({ id: "m1", deleted_at: 123 });
|
|
|
|
|
|
|
|
|
|
expect(ws.send).not.toHaveBeenCalled();
|
|
|
|
|
});
|
2026-05-14 18:44:47 +07:00
|
|
|
|
|
|
|
|
it("broadcasts to multiple open clients", () => {
|
|
|
|
|
const ws1 = client();
|
|
|
|
|
const ws2 = client();
|
|
|
|
|
const ws3 = client();
|
|
|
|
|
const broadcaster = createBroadcaster();
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
broadcaster.addClient(ws1);
|
|
|
|
|
broadcaster.addClient(ws2);
|
|
|
|
|
broadcaster.addClient(ws3);
|
2026-05-14 18:44:47 +07:00
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
broadcaster.messageCreated(messageRecord());
|
2026-05-14 18:44:47 +07:00
|
|
|
|
|
|
|
|
expect(ws1.send).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(ws2.send).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(ws3.send).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("failed send on one client does not prevent another client from receiving event", () => {
|
|
|
|
|
const ws1 = client();
|
|
|
|
|
const ws2 = client();
|
|
|
|
|
const ws3 = client();
|
|
|
|
|
const broadcaster = createBroadcaster();
|
|
|
|
|
|
|
|
|
|
// ws1 throws on send
|
|
|
|
|
ws1.send.mockImplementation(() => {
|
|
|
|
|
throw new Error("Send failed");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
broadcaster.addClient(ws1);
|
|
|
|
|
broadcaster.addClient(ws2);
|
|
|
|
|
broadcaster.addClient(ws3);
|
2026-05-14 18:44:47 +07:00
|
|
|
|
|
|
|
|
broadcaster.messageUpdated({
|
|
|
|
|
id: "m1",
|
|
|
|
|
content: "updated",
|
2026-05-15 07:13:37 +07:00
|
|
|
});
|
2026-05-14 18:44:47 +07:00
|
|
|
|
|
|
|
|
// ws1 attempted send (threw)
|
|
|
|
|
expect(ws1.send).toHaveBeenCalledTimes(1);
|
|
|
|
|
// ws2 and ws3 should still receive the event
|
|
|
|
|
expect(ws2.send).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(ws3.send).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("clientCount tracks add/remove", () => {
|
|
|
|
|
const ws1 = client();
|
|
|
|
|
const ws2 = client();
|
|
|
|
|
const broadcaster = createBroadcaster();
|
|
|
|
|
|
|
|
|
|
expect(broadcaster.clientCount()).toBe(0);
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
broadcaster.addClient(ws1);
|
2026-05-14 18:44:47 +07:00
|
|
|
expect(broadcaster.clientCount()).toBe(1);
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
broadcaster.addClient(ws2);
|
2026-05-14 18:44:47 +07:00
|
|
|
expect(broadcaster.clientCount()).toBe(2);
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
broadcaster.removeClient(ws1);
|
2026-05-14 18:44:47 +07:00
|
|
|
expect(broadcaster.clientCount()).toBe(1);
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
broadcaster.removeClient(ws2);
|
2026-05-14 18:44:47 +07:00
|
|
|
expect(broadcaster.clientCount()).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("payload includes numeric timestamp", () => {
|
|
|
|
|
const ws = client();
|
|
|
|
|
const broadcaster = createBroadcaster();
|
|
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
broadcaster.addClient(ws);
|
|
|
|
|
broadcaster.attachmentCreated(attachmentRecord());
|
2026-05-14 18:44:47 +07:00
|
|
|
|
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
|
|
|
const payload = JSON.parse(ws.send.mock.calls[0][0]);
|
|
|
|
|
expect(payload.timestamp).toBeDefined();
|
|
|
|
|
expect(typeof payload.timestamp).toBe("number");
|
|
|
|
|
expect(payload.timestamp).toBeGreaterThan(0);
|
|
|
|
|
});
|
2026-05-14 18:40:34 +07:00
|
|
|
});
|