diff options
Diffstat (limited to 'packages/api/tests')
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 113 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 106 |
2 files changed, 219 insertions, 0 deletions
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts new file mode 100644 index 0000000..17b0bff --- /dev/null +++ b/packages/api/tests/agent-manager.test.ts @@ -0,0 +1,113 @@ +import type { AgentEvent } from "@dispatch/core"; +import { describe, expect, it, vi } from "vitest"; + +// Mock @dispatch/core's Agent to avoid real LLM calls +vi.mock("@dispatch/core", async () => { + const actual = await vi.importActual<typeof import("@dispatch/core")>("@dispatch/core"); + return { + ...actual, + Agent: class MockAgent { + status = "idle"; + messages: unknown[] = []; + async *run(_message: string) { + yield { type: "status", status: "running" } as const; + await new Promise<void>((r) => setTimeout(r, 10)); + yield { type: "text-delta", delta: "Hello " } as const; + yield { type: "text-delta", delta: "world" } as const; + yield { + type: "done", + message: { role: "assistant", content: "Hello world" }, + } as const; + yield { type: "status", status: "idle" } as const; + } + }, + }; +}); + +// Import after mock is defined (Vitest hoists vi.mock automatically) +const { AgentManager } = await import("../src/agent-manager.js"); + +describe("AgentManager", () => { + it("initial status is idle", () => { + const manager = new AgentManager(); + expect(manager.getStatus()).toBe("idle"); + }); + + it("initial messageCount is 0", () => { + const manager = new AgentManager(); + expect(manager.getMessageCount()).toBe(0); + }); + + it("event listeners receive events during processMessage", async () => { + const manager = new AgentManager(); + const events: AgentEvent[] = []; + manager.onEvent((event) => { + events.push(event); + }); + + await manager.processMessage("test"); + + expect(events.length).toBeGreaterThan(0); + expect(events[0]).toEqual({ type: "status", status: "running" }); + + const lastEvent = events[events.length - 1]; + expect(lastEvent).toEqual({ type: "status", status: "idle" }); + + const doneEvent = events.find((e) => e.type === "done"); + expect(doneEvent).toBeDefined(); + }); + + it("emits text-delta events during processMessage", async () => { + const manager = new AgentManager(); + const events: AgentEvent[] = []; + manager.onEvent((event) => { + events.push(event); + }); + + await manager.processMessage("hello"); + + const textDeltas = events.filter((e) => e.type === "text-delta"); + expect(textDeltas.length).toBeGreaterThan(0); + }); + + it("messageCount increments after processMessage", async () => { + const manager = new AgentManager(); + await manager.processMessage("hello"); + expect(manager.getMessageCount()).toBe(1); + await manager.processMessage("world"); + expect(manager.getMessageCount()).toBe(2); + }); + + it("status returns to idle after processMessage completes", async () => { + const manager = new AgentManager(); + await manager.processMessage("test"); + expect(manager.getStatus()).toBe("idle"); + }); + + it("unsubscribe removes listener", async () => { + const manager = new AgentManager(); + const events: AgentEvent[] = []; + const unsubscribe = manager.onEvent((event) => { + events.push(event); + }); + + unsubscribe(); + await manager.processMessage("test"); + + expect(events.length).toBe(0); + }); + + it("multiple listeners all receive events", async () => { + const manager = new AgentManager(); + const listener1 = vi.fn(); + const listener2 = vi.fn(); + + manager.onEvent(listener1); + manager.onEvent(listener2); + + await manager.processMessage("test"); + + expect(listener1).toHaveBeenCalled(); + expect(listener2).toHaveBeenCalled(); + }); +}); diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts new file mode 100644 index 0000000..d5384b3 --- /dev/null +++ b/packages/api/tests/routes.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +// Mock @dispatch/core's Agent to avoid real LLM calls +vi.mock("@dispatch/core", async () => { + const actual = await vi.importActual<typeof import("@dispatch/core")>("@dispatch/core"); + return { + ...actual, + Agent: class MockAgent { + status = "idle"; + messages: unknown[] = []; + async *run(_message: string) { + yield { type: "status", status: "running" } as const; + // Simulate some processing time so status stays "running" + await new Promise<void>((r) => setTimeout(r, 100)); + yield { type: "text-delta", delta: "Hello " } as const; + yield { type: "text-delta", delta: "world" } as const; + yield { + type: "done", + message: { role: "assistant", content: "Hello world" }, + } as const; + yield { type: "status", status: "idle" } as const; + } + }, + }; +}); + +const { app } = await import("../src/app.js"); + +describe("GET /health", () => { + it("returns 200 with ok: true", async () => { + const res = await app.request("/health"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ ok: true }); + }); +}); + +describe("GET /status", () => { + it("returns idle status initially", async () => { + const res = await app.request("/status"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("idle"); + expect(typeof body.messageCount).toBe("number"); + }); +}); + +describe("POST /chat", () => { + it("returns 200 with valid message", async () => { + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hello world" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ status: "ok" }); + }); + + it("returns 400 with empty message", async () => { + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 with whitespace-only message", async () => { + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: " " }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 with missing message field", async () => { + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("returns 409 when agent is already running", async () => { + // Start a message (non-blocking) + await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "first message" }), + }); + + // Small delay to let the async generator start and emit "running" status + await new Promise<void>((r) => setTimeout(r, 20)); + + // Immediately send a second — agent should be running + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "second message" }), + }); + expect(res.status).toBe(409); + }); +}); |
