forked from EduCraft/curriculum-project-hub
36c0801e6f
- 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
136 lines
4.4 KiB
TypeScript
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" }],
|
|
};
|
|
}
|