forked from EduCraft/curriculum-project-hub
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
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
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" }],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user