Files
curriculum-project-hub/hub/test/unit/trigger-queue.test.ts
T
hongjr03 36c0801e6f feat: add per-project message trigger wait queue
- TriggerQueue: FIFO per-project queue, max 5 items, 5min TTL expiry
- Replace reject-when-locked with queue: users get position feedback
- Run completion auto-starts next queued trigger (skips expired items)
- /reset clears project queue; 60s periodic purge interval
- Add unit tests (10) for queue lifecycle and edge cases
2026-07-08 17:31:43 +08:00

136 lines
4.4 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { TriggerQueue, type QueuedTrigger } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
describe("TriggerQueue", () => {
afterEach(() => {
vi.useRealTimers();
});
it("dequeues triggers in FIFO order", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
queue.enqueue("project-1", makeTrigger("second"));
expect(queue.dequeue("project-1")?.prompt).toBe("first");
expect(queue.dequeue("project-1")?.prompt).toBe("second");
});
it("returns the 1-based queue position from enqueue", () => {
const queue = new TriggerQueue();
expect(queue.enqueue("project-1", makeTrigger("first"))).toBe(1);
expect(queue.enqueue("project-1", makeTrigger("second"))).toBe(2);
expect(queue.enqueue("project-1", makeTrigger("third"))).toBe(3);
});
it("returns 0 when the project queue is full", () => {
const queue = new TriggerQueue({ maxQueueSize: 2 });
expect(queue.enqueue("project-1", makeTrigger("first"))).toBe(1);
expect(queue.enqueue("project-1", makeTrigger("second"))).toBe(2);
expect(queue.enqueue("project-1", makeTrigger("third"))).toBe(0);
expect(queue.length("project-1")).toBe(2);
});
it("returns null when dequeueing an empty queue", () => {
const queue = new TriggerQueue();
expect(queue.dequeue("project-1")).toBeNull();
});
it("peeks without removing the trigger", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
expect(queue.peek("project-1")?.prompt).toBe("first");
expect(queue.length("project-1")).toBe(1);
expect(queue.dequeue("project-1")?.prompt).toBe("first");
});
it("reports length and pending state per project", () => {
const queue = new TriggerQueue();
expect(queue.length("project-1")).toBe(0);
expect(queue.hasPending("project-1")).toBe(false);
queue.enqueue("project-1", makeTrigger("first"));
expect(queue.length("project-1")).toBe(1);
expect(queue.hasPending("project-1")).toBe(true);
});
it("purges expired triggers", () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const queue = new TriggerQueue({ maxWaitMs: 100 });
queue.enqueue("project-1", makeTrigger("old"));
vi.setSystemTime(1_050);
queue.enqueue("project-1", makeTrigger("fresh"));
vi.setSystemTime(1_101);
expect(queue.purgeExpired()).toBe(1);
expect(queue.length("project-1")).toBe(1);
expect(queue.dequeue("project-1")?.prompt).toBe("fresh");
});
it("clears all triggers for a project", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
queue.enqueue("project-1", makeTrigger("second"));
queue.enqueue("project-2", makeTrigger("other"));
expect(queue.clear("project-1")).toBe(2);
expect(queue.length("project-1")).toBe(0);
expect(queue.length("project-2")).toBe(1);
});
it("clears all project queues", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
queue.enqueue("project-2", makeTrigger("other"));
queue.clearAll();
expect(queue.hasPending("project-1")).toBe(false);
expect(queue.hasPending("project-2")).toBe(false);
});
it("keeps multiple project queues independent", () => {
const queue = new TriggerQueue();
expect(queue.enqueue("project-1", makeTrigger("p1-first"))).toBe(1);
expect(queue.enqueue("project-2", makeTrigger("p2-first"))).toBe(1);
expect(queue.enqueue("project-1", makeTrigger("p1-second"))).toBe(2);
expect(queue.dequeue("project-2")?.prompt).toBe("p2-first");
expect(queue.dequeue("project-1")?.prompt).toBe("p1-first");
expect(queue.dequeue("project-1")?.prompt).toBe("p1-second");
});
});
function makeTrigger(prompt: string): Omit<QueuedTrigger, "projectId" | "enqueuedAt"> {
return {
chatId: "chat-1",
prompt,
msg: makeMessage(prompt),
senderOpenId: "ou-user",
actor: { feishuOpenId: "ou-user", chatId: "chat-1" },
};
}
function makeMessage(prompt: string): MessageReceiveEvent["message"] {
return {
message_id: `message-${prompt}`,
chat_id: "chat-1",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: `@_user_1 ${prompt}` }),
mentions: [{ key: "@_user_1", id: { open_id: "ou-bot" }, name: "Bot" }],
};
}