✅ TEST: full frontend web nextjs test suite
This commit is contained in:
parent
371035978c
commit
4965e3ca67
269
apps/web/app/api/chat/chat.test.ts
Normal file
269
apps/web/app/api/chat/chat.test.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock active-runs module
|
||||
vi.mock("@/lib/active-runs", () => ({
|
||||
startRun: vi.fn(),
|
||||
hasActiveRun: vi.fn(() => false),
|
||||
subscribeToRun: vi.fn(),
|
||||
persistUserMessage: vi.fn(),
|
||||
abortRun: vi.fn(() => false),
|
||||
getActiveRun: vi.fn(),
|
||||
getRunningSessionIds: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
// Mock workspace module
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
resolveAgentWorkspacePrefix: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe("Chat API routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// Re-wire mocks
|
||||
vi.mock("@/lib/active-runs", () => ({
|
||||
startRun: vi.fn(),
|
||||
hasActiveRun: vi.fn(() => false),
|
||||
subscribeToRun: vi.fn(),
|
||||
persistUserMessage: vi.fn(),
|
||||
abortRun: vi.fn(() => false),
|
||||
getActiveRun: vi.fn(),
|
||||
getRunningSessionIds: vi.fn(() => []),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
resolveAgentWorkspacePrefix: vi.fn(() => null),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── POST /api/chat ──────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/chat", () => {
|
||||
it("returns 400 when no user message text", async () => {
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", parts: [{ type: "text", text: "" }] }],
|
||||
}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 409 when active run exists for session", async () => {
|
||||
const { hasActiveRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(hasActiveRun).mockReturnValue(true);
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", parts: [{ type: "text", text: "hello" }] }],
|
||||
sessionId: "s1",
|
||||
}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("starts a run and returns streaming response", async () => {
|
||||
const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(hasActiveRun).mockReturnValue(false);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{ id: "m1", role: "user", parts: [{ type: "text", text: "hello" }] },
|
||||
],
|
||||
sessionId: "s1",
|
||||
}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
expect(startRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists user message when sessionId provided", async () => {
|
||||
const { hasActiveRun, subscribeToRun, persistUserMessage } = await import("@/lib/active-runs");
|
||||
vi.mocked(hasActiveRun).mockReturnValue(false);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
||||
],
|
||||
sessionId: "s1",
|
||||
}),
|
||||
});
|
||||
await POST(req);
|
||||
expect(persistUserMessage).toHaveBeenCalledWith("s1", expect.objectContaining({ id: "m1" }));
|
||||
});
|
||||
|
||||
it("resolves workspace file paths in message", async () => {
|
||||
const { resolveAgentWorkspacePrefix } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveAgentWorkspacePrefix).mockReturnValue("workspace");
|
||||
const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(hasActiveRun).mockReturnValue(false);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "[Context: workspace file 'doc.md']" }],
|
||||
},
|
||||
],
|
||||
sessionId: "s1",
|
||||
}),
|
||||
});
|
||||
await POST(req);
|
||||
expect(startRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("workspace/doc.md"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/chat/stop ────────────────────────────────────────
|
||||
|
||||
describe("POST /api/chat/stop", () => {
|
||||
it("returns 400 when sessionId missing", async () => {
|
||||
const { POST } = await import("./stop/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("aborts run and returns result", async () => {
|
||||
const { abortRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(abortRun).mockReturnValue(true);
|
||||
|
||||
const { POST } = await import("./stop/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: "s1" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("returns aborted=false for unknown session", async () => {
|
||||
const { abortRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(abortRun).mockReturnValue(false);
|
||||
|
||||
const { POST } = await import("./stop/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: "nonexistent" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
expect(json.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("handles invalid JSON body gracefully", async () => {
|
||||
const { POST } = await import("./stop/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/chat/active ────────────────────────────────────────
|
||||
|
||||
describe("GET /api/chat/active", () => {
|
||||
it("returns empty sessionIds when no active runs", async () => {
|
||||
const { GET } = await import("./active/route.js");
|
||||
const res = GET();
|
||||
const json = await res.json();
|
||||
expect(json.sessionIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns active session IDs", async () => {
|
||||
const { getRunningSessionIds } = await import("@/lib/active-runs");
|
||||
vi.mocked(getRunningSessionIds).mockReturnValue(["s1", "s2"]);
|
||||
|
||||
const { GET } = await import("./active/route.js");
|
||||
const res = GET();
|
||||
const json = await res.json();
|
||||
expect(json.sessionIds).toEqual(["s1", "s2"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/chat/stream ───────────────────────────────────────
|
||||
|
||||
describe("GET /api/chat/stream", () => {
|
||||
it("returns 400 when sessionId is missing", async () => {
|
||||
const { GET } = await import("./stream/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stream");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when no run exists for session", async () => {
|
||||
const { getActiveRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(getActiveRun).mockReturnValue(undefined);
|
||||
|
||||
const { GET } = await import("./stream/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stream?sessionId=nonexistent");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns SSE stream for active run", async () => {
|
||||
const { getActiveRun, subscribeToRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(getActiveRun).mockReturnValue({ status: "running" } as never);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
|
||||
const { GET } = await import("./stream/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stream?sessionId=s1");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
expect(res.headers.get("X-Run-Active")).toBe("true");
|
||||
});
|
||||
|
||||
it("returns X-Run-Active=false for completed run", async () => {
|
||||
const { getActiveRun, subscribeToRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(getActiveRun).mockReturnValue({ status: "completed" } as never);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
|
||||
const { GET } = await import("./stream/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stream?sessionId=s1");
|
||||
const res = await GET(req);
|
||||
expect(res.headers.get("X-Run-Active")).toBe("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
153
apps/web/app/api/cron/cron.test.ts
Normal file
153
apps/web/app/api/cron/cron.test.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
||||
}));
|
||||
|
||||
// Mock node:os
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
describe("Cron API routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/cron/jobs ─────────────────────────────────────────
|
||||
|
||||
describe("GET /api/cron/jobs", () => {
|
||||
it("returns empty jobs when no config file", async () => {
|
||||
const { GET } = await import("./jobs/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.jobs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns jobs from config file", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const cronStore = {
|
||||
version: 1,
|
||||
jobs: [
|
||||
{ id: "j1", name: "Daily sync", schedule: "0 8 * * *", enabled: true, command: "sync" },
|
||||
],
|
||||
};
|
||||
vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(cronStore) as never);
|
||||
|
||||
const { GET } = await import("./jobs/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.jobs).toHaveLength(1);
|
||||
expect(json.jobs[0].name).toBe("Daily sync");
|
||||
});
|
||||
|
||||
it("handles corrupt jobs file gracefully", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockReturnValue("not json" as never);
|
||||
|
||||
const { GET } = await import("./jobs/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.jobs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/cron/jobs/[jobId]/runs ────────────────────────────
|
||||
|
||||
describe("GET /api/cron/jobs/[jobId]/runs", () => {
|
||||
it("returns empty entries when no runs file", async () => {
|
||||
const { GET } = await import("./jobs/[jobId]/runs/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/cron/jobs/j1/runs"),
|
||||
{ params: Promise.resolve({ jobId: "j1" }) },
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(json.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns run entries from jsonl file", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const lines = [
|
||||
JSON.stringify({ ts: 1000, jobId: "j1", action: "finished", status: "completed", summary: "Done" }),
|
||||
JSON.stringify({ ts: 2000, jobId: "j1", action: "finished", status: "completed", summary: "In progress" }),
|
||||
].join("\n");
|
||||
vi.mocked(mockReadFile).mockReturnValue(lines as never);
|
||||
|
||||
const { GET } = await import("./jobs/[jobId]/runs/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/cron/jobs/j1/runs"),
|
||||
{ params: Promise.resolve({ jobId: "j1" }) },
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(json.entries.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("respects limit query param", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const lines = Array.from({ length: 50 }, (_, i) =>
|
||||
JSON.stringify({ ts: i, status: "completed" }),
|
||||
).join("\n");
|
||||
vi.mocked(mockReadFile).mockReturnValue(lines as never);
|
||||
|
||||
const { GET } = await import("./jobs/[jobId]/runs/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/cron/jobs/j1/runs?limit=5"),
|
||||
{ params: Promise.resolve({ jobId: "j1" }) },
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(json.entries.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/cron/runs/[sessionId] ─────────────────────────────
|
||||
|
||||
describe("GET /api/cron/runs/[sessionId]", () => {
|
||||
it("returns 404 when session not found", async () => {
|
||||
const { GET } = await import("./runs/[sessionId]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ sessionId: "nonexistent" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/cron/runs/search-transcript ───────────────────────
|
||||
|
||||
describe("GET /api/cron/runs/search-transcript", () => {
|
||||
it("returns 400 when missing required params", async () => {
|
||||
const { GET } = await import("./runs/search-transcript/route.js");
|
||||
const req = new Request("http://localhost/api/cron/runs/search-transcript");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when no transcript found", async () => {
|
||||
const { GET } = await import("./runs/search-transcript/route.js");
|
||||
const req = new Request("http://localhost/api/cron/runs/search-transcript?jobId=j1&runAtMs=1000");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
144
apps/web/app/api/sessions/sessions.test.ts
Normal file
144
apps/web/app/api/sessions/sessions.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
||||
}));
|
||||
|
||||
// Mock node:os
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
describe("Sessions, Memories & Skills API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/sessions ──────────────────────────────────────────
|
||||
|
||||
describe("GET /api/sessions", () => {
|
||||
it("returns empty agents and sessions when no dir exists", async () => {
|
||||
const { GET } = await import("./route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.agents).toEqual([]);
|
||||
expect(json.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns sessions from agent directories", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockImplementation((dir) => {
|
||||
const s = String(dir);
|
||||
if (s.endsWith("agents")) {return ["main" as never];}
|
||||
if (s.endsWith("sessions")) {return ["sessions.json" as never];}
|
||||
return [];
|
||||
});
|
||||
const sessionsData = {
|
||||
"s1": { label: "Chat 1", displayName: "Chat 1", channel: "webchat", updatedAt: Date.now() },
|
||||
};
|
||||
vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessionsData) as never);
|
||||
|
||||
const { GET } = await import("./route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.sessions.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/sessions/[sessionId] ──────────────────────────────
|
||||
|
||||
describe("GET /api/sessions/[sessionId]", () => {
|
||||
it("returns 404 when session not found", async () => {
|
||||
const { GET } = await import("./[sessionId]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ sessionId: "nonexistent" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent session ID", async () => {
|
||||
const { GET } = await import("./[sessionId]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ sessionId: "missing-id" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/memories ──────────────────────────────────────────
|
||||
|
||||
describe("GET /api/memories", () => {
|
||||
it("returns null mainMemory when no memory file exists", async () => {
|
||||
const { existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(false);
|
||||
|
||||
const { GET } = await import("../memories/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.mainMemory).toBeNull();
|
||||
});
|
||||
|
||||
it("returns memory content when file exists", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith("MEMORY.md") || s.endsWith("memory.md")) {return true;}
|
||||
return false;
|
||||
});
|
||||
vi.mocked(mockReadFile).mockReturnValue("# My memories\n\n- Remember X" as never);
|
||||
vi.mocked(mockReaddir).mockReturnValue([]);
|
||||
|
||||
const { GET } = await import("../memories/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.mainMemory).toContain("memories");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/skills ────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/skills", () => {
|
||||
it("returns empty skills when no skills directories exist", async () => {
|
||||
const { GET } = await import("../skills/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.skills).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns skills from directory", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockImplementation((dir) => {
|
||||
const s = String(dir);
|
||||
if (s.endsWith("skills")) {return ["my-skill" as never];}
|
||||
return [];
|
||||
});
|
||||
vi.mocked(mockReadFile).mockReturnValue("---\nname: My Skill\n---\n# Skill content" as never);
|
||||
|
||||
const { GET } = await import("../skills/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.skills.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
304
apps/web/app/api/web-sessions/web-sessions.test.ts
Normal file
304
apps/web/app/api/web-sessions/web-sessions.test.ts
Normal file
@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => "[]"),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
appendFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:os
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
// Mock node:crypto
|
||||
vi.mock("node:crypto", () => ({
|
||||
randomUUID: vi.fn(() => "test-uuid-1234"),
|
||||
}));
|
||||
|
||||
describe("Web Sessions API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => "[]"),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
appendFileSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
vi.mock("node:crypto", () => ({
|
||||
randomUUID: vi.fn(() => "test-uuid-1234"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/web-sessions ──────────────────────────────────────
|
||||
|
||||
describe("GET /api/web-sessions", () => {
|
||||
it("returns empty sessions when no index exists", async () => {
|
||||
const { GET } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns global sessions when no filePath param", async () => {
|
||||
const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const sessions = [
|
||||
{ id: "s1", title: "Chat 1", createdAt: 1, updatedAt: 1, messageCount: 0 },
|
||||
{ id: "s2", title: "File Chat", createdAt: 2, updatedAt: 2, messageCount: 1, filePath: "doc.md" },
|
||||
];
|
||||
vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessions) as never);
|
||||
|
||||
const { GET } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.sessions).toHaveLength(1);
|
||||
expect(json.sessions[0].id).toBe("s1");
|
||||
});
|
||||
|
||||
it("filters sessions by filePath param", async () => {
|
||||
const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const sessions = [
|
||||
{ id: "s1", title: "Global", createdAt: 1, updatedAt: 1, messageCount: 0 },
|
||||
{ id: "s2", title: "Doc Chat", createdAt: 2, updatedAt: 2, messageCount: 1, filePath: "doc.md" },
|
||||
];
|
||||
vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessions) as never);
|
||||
|
||||
const { GET } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions?filePath=doc.md");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.sessions).toHaveLength(1);
|
||||
expect(json.sessions[0].filePath).toBe("doc.md");
|
||||
});
|
||||
|
||||
it("returns empty when no matching filePath sessions", async () => {
|
||||
const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockReturnValue("[]" as never);
|
||||
|
||||
const { GET } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions?filePath=nonexistent.md");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/web-sessions ────────────────────────────────────
|
||||
|
||||
describe("POST /api/web-sessions", () => {
|
||||
it("creates a new session with default title", async () => {
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
expect(json.session.id).toBe("test-uuid-1234");
|
||||
expect(json.session.title).toBe("New Chat");
|
||||
expect(json.session.messageCount).toBe(0);
|
||||
expect(mockWrite).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates session with custom title", async () => {
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "My Chat" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
expect(json.session.title).toBe("My Chat");
|
||||
});
|
||||
|
||||
it("creates file-scoped session with filePath", async () => {
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "File Chat", filePath: "readme.md" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
expect(json.session.filePath).toBe("readme.md");
|
||||
});
|
||||
|
||||
it("handles invalid JSON body gracefully", async () => {
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
// Falls back to default title
|
||||
expect(json.session.title).toBe("New Chat");
|
||||
});
|
||||
|
||||
it("creates jsonl file for new session", async () => {
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
await POST(req);
|
||||
// Should write at least the index.json and the empty .jsonl
|
||||
expect(mockWrite).toHaveBeenCalled();
|
||||
// Verify that one of the calls is to the jsonl file
|
||||
const calls = mockWrite.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((c) => c.endsWith(".jsonl"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/web-sessions/[id] ────────────────────────────────
|
||||
|
||||
describe("GET /api/web-sessions/[id]", () => {
|
||||
it("returns session messages", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const lines = [
|
||||
JSON.stringify({ id: "m1", role: "user", content: "hello" }),
|
||||
JSON.stringify({ id: "m2", role: "assistant", content: "hi" }),
|
||||
].join("\n");
|
||||
vi.mocked(mockReadFile).mockReturnValue(lines as never);
|
||||
|
||||
const { GET } = await import("./[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/web-sessions/s1"),
|
||||
{ params: Promise.resolve({ id: "s1" }) },
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(json.id).toBe("s1");
|
||||
expect(json.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns 404 when session file does not exist", async () => {
|
||||
const { existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(false);
|
||||
|
||||
const { GET } = await import("./[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/web-sessions/nonexistent"),
|
||||
{ params: Promise.resolve({ id: "nonexistent" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("handles empty session file", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockReturnValue("" as never);
|
||||
|
||||
const { GET } = await import("./[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/web-sessions/s1"),
|
||||
{ params: Promise.resolve({ id: "s1" }) },
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(json.messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/web-sessions/[id]/messages ──────────────────────
|
||||
|
||||
describe("POST /api/web-sessions/[id]/messages", () => {
|
||||
it("appends messages to session file", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith("index.json")) {
|
||||
return JSON.stringify([{ id: "s1", title: "Chat", createdAt: 1, updatedAt: 1, messageCount: 0 }]) as never;
|
||||
}
|
||||
return "" as never;
|
||||
});
|
||||
|
||||
const { POST } = await import("./[id]/messages/route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions/s1/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ id: "m1", role: "user", content: "hello" }],
|
||||
}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "s1" }) });
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-creates session file if missing", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith(".jsonl")) {return false;}
|
||||
return true;
|
||||
});
|
||||
vi.mocked(mockReadFile).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith("index.json")) {return "[]" as never;}
|
||||
return "" as never;
|
||||
});
|
||||
|
||||
const { POST } = await import("./[id]/messages/route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions/new-s/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ id: "m1", role: "user", content: "first message" }],
|
||||
}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "new-s" }) });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("updates session title when provided", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith("index.json")) {
|
||||
return JSON.stringify([{ id: "s1", title: "Old Title", createdAt: 1, updatedAt: 1, messageCount: 0 }]) as never;
|
||||
}
|
||||
return "" as never;
|
||||
});
|
||||
|
||||
const { POST } = await import("./[id]/messages/route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions/s1/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ id: "m1", role: "user", content: "hello" }],
|
||||
title: "New Title",
|
||||
}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "s1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
// Verify index was written with new title
|
||||
expect(mockWrite).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
273
apps/web/app/api/workspace/db.test.ts
Normal file
273
apps/web/app/api/workspace/db.test.ts
Normal file
@ -0,0 +1,273 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock workspace (include ALL exports used by the routes)
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
safeResolvePath: vi.fn(() => null),
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
duckdbPath: vi.fn(() => null),
|
||||
duckdbQuery: vi.fn(() => []),
|
||||
duckdbQueryAsync: vi.fn(async () => []),
|
||||
duckdbQueryOnFile: vi.fn(() => []),
|
||||
duckdbQueryOnFileAsync: vi.fn(async () => []),
|
||||
duckdbExecOnFile: vi.fn(() => true),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
isDatabaseFile: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock report-filters
|
||||
vi.mock("@/lib/report-filters", () => ({
|
||||
buildFilterClauses: vi.fn(() => []),
|
||||
injectFilters: vi.fn((sql: string) => sql),
|
||||
checkSqlSafety: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe("Workspace DB & Reports API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
safeResolvePath: vi.fn(() => null),
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
duckdbPath: vi.fn(() => null),
|
||||
duckdbQuery: vi.fn(() => []),
|
||||
duckdbQueryAsync: vi.fn(async () => []),
|
||||
duckdbQueryOnFile: vi.fn(() => []),
|
||||
duckdbQueryOnFileAsync: vi.fn(async () => []),
|
||||
duckdbExecOnFile: vi.fn(() => true),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
isDatabaseFile: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("@/lib/report-filters", () => ({
|
||||
buildFilterClauses: vi.fn(() => []),
|
||||
injectFilters: vi.fn((sql: string) => sql),
|
||||
checkSqlSafety: vi.fn(() => null),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/db/query ───────────────────────────────
|
||||
|
||||
describe("POST /api/workspace/db/query", () => {
|
||||
it("returns 400 for missing sql", async () => {
|
||||
const { POST } = await import("./db/query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "test.duckdb" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for missing path", async () => {
|
||||
const { POST } = await import("./db/query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: "SELECT 1" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects mutation queries with 403", async () => {
|
||||
const { safeResolvePath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
|
||||
|
||||
const { POST } = await import("./db/query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "test.duckdb", sql: "DROP TABLE users" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("executes query and returns rows", async () => {
|
||||
const { safeResolvePath, duckdbQueryOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: 1, name: "test" }]);
|
||||
|
||||
const { POST } = await import("./db/query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "test.duckdb", sql: "SELECT * FROM t" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.rows).toEqual([{ id: 1, name: "test" }]);
|
||||
});
|
||||
|
||||
it("returns empty rows for empty result", async () => {
|
||||
const { safeResolvePath, duckdbQueryOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([]);
|
||||
|
||||
const { POST } = await import("./db/query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "test.duckdb", sql: "SELECT * FROM empty" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
expect(json.rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/db/introspect ───────────────────────────
|
||||
|
||||
describe("GET /api/workspace/db/introspect", () => {
|
||||
it("returns 400 for missing path", async () => {
|
||||
const { GET } = await import("./db/introspect/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/introspect");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when file not found", async () => {
|
||||
const { safeResolvePath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./db/introspect/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/introspect?path=missing.duckdb");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns schema when database exists", async () => {
|
||||
const { safeResolvePath, resolveDuckdbBin, duckdbQueryOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
|
||||
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([
|
||||
{ table_name: "users", column_name: "id", data_type: "INTEGER", is_nullable: "NO" },
|
||||
]);
|
||||
|
||||
const { GET } = await import("./db/introspect/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/introspect?path=test.duckdb");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.tables).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/reports/execute ────────────────────────
|
||||
|
||||
describe("POST /api/workspace/reports/execute", () => {
|
||||
it("returns 400 for missing sql", async () => {
|
||||
const { POST } = await import("./reports/execute/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects mutation SQL with 403", async () => {
|
||||
const { checkSqlSafety } = await import("@/lib/report-filters");
|
||||
vi.mocked(checkSqlSafety).mockReturnValue("Only SELECT queries allowed");
|
||||
|
||||
const { POST } = await import("./reports/execute/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: "DROP TABLE users" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("executes report query successfully", async () => {
|
||||
const { checkSqlSafety } = await import("@/lib/report-filters");
|
||||
vi.mocked(checkSqlSafety).mockReturnValue(null);
|
||||
const { duckdbQuery } = await import("@/lib/workspace");
|
||||
vi.mocked(duckdbQuery).mockReturnValue([{ count: 42 }]);
|
||||
|
||||
const { POST } = await import("./reports/execute/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: "SELECT COUNT(*) as count FROM v_deals" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.rows).toEqual([{ count: 42 }]);
|
||||
});
|
||||
|
||||
it("applies filters to SQL", async () => {
|
||||
const { checkSqlSafety, buildFilterClauses, injectFilters } = await import("@/lib/report-filters");
|
||||
vi.mocked(checkSqlSafety).mockReturnValue(null);
|
||||
vi.mocked(buildFilterClauses).mockReturnValue(['"Status" = \'Active\'']);
|
||||
vi.mocked(injectFilters).mockReturnValue("SELECT * FROM filtered");
|
||||
const { duckdbQuery } = await import("@/lib/workspace");
|
||||
vi.mocked(duckdbQuery).mockReturnValue([{ count: 10 }]);
|
||||
|
||||
const { POST } = await import("./reports/execute/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sql: "SELECT * FROM v_deals",
|
||||
filters: [{ id: "s", column: "Status", value: { type: "select", value: "Active" } }],
|
||||
}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
expect(buildFilterClauses).toHaveBeenCalled();
|
||||
expect(injectFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/query ─────────────────────────────────
|
||||
|
||||
describe("POST /api/workspace/query", () => {
|
||||
it("returns 400 for missing sql", async () => {
|
||||
const { POST } = await import("./query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("executes query and returns rows", async () => {
|
||||
const { duckdbQuery } = await import("@/lib/workspace");
|
||||
vi.mocked(duckdbQuery).mockReturnValue([{ id: 1 }]);
|
||||
|
||||
const { POST } = await import("./query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: "SELECT 1 as id" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.rows).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it("rejects mutation SQL with 403", async () => {
|
||||
const { POST } = await import("./query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: "DELETE FROM users" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
292
apps/web/app/api/workspace/file-ops.test.ts
Normal file
292
apps/web/app/api/workspace/file-ops.test.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock("node:fs", () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => false })),
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
renameSync: vi.fn(),
|
||||
cpSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock workspace utilities
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
readWorkspaceFile: vi.fn(),
|
||||
safeResolvePath: vi.fn(),
|
||||
safeResolveNewPath: vi.fn(),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
resolveWorkspaceRoot: vi.fn(() => "/ws"),
|
||||
}));
|
||||
|
||||
describe("Workspace File Operations API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:fs", () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => false })),
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
renameSync: vi.fn(),
|
||||
cpSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
readWorkspaceFile: vi.fn(),
|
||||
safeResolvePath: vi.fn(),
|
||||
safeResolveNewPath: vi.fn(),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
resolveWorkspaceRoot: vi.fn(() => "/ws"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/file ────────────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/file", () => {
|
||||
it("returns 400 when path param is missing", async () => {
|
||||
const { GET } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(400);
|
||||
const json = await res.json();
|
||||
expect(json.error).toContain("path");
|
||||
});
|
||||
|
||||
it("returns file content when found", async () => {
|
||||
const { readWorkspaceFile } = await import("@/lib/workspace");
|
||||
vi.mocked(readWorkspaceFile).mockReturnValue({ content: "# Hello", type: "markdown" });
|
||||
|
||||
const { GET } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file?path=doc.md");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.content).toBe("# Hello");
|
||||
expect(json.type).toBe("markdown");
|
||||
});
|
||||
|
||||
it("returns 404 when file not found", async () => {
|
||||
const { readWorkspaceFile } = await import("@/lib/workspace");
|
||||
vi.mocked(readWorkspaceFile).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file?path=missing.md");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/file ───────────────────────────────────
|
||||
|
||||
describe("POST /api/workspace/file", () => {
|
||||
it("writes file content successfully", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md");
|
||||
const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "doc.md", content: "# Hello" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
expect(mockMkdir).toHaveBeenCalled();
|
||||
expect(mockWrite).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for missing path", async () => {
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "text" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for missing content", async () => {
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "doc.md" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for path traversal", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue(null);
|
||||
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "../etc/passwd", content: "hack" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON body", async () => {
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 500 on write error", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md");
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockWrite).mockImplementation(() => { throw new Error("EACCES"); });
|
||||
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "doc.md", content: "text" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /api/workspace/file ─────────────────────────────────
|
||||
|
||||
describe("DELETE /api/workspace/file", () => {
|
||||
it("deletes file successfully", async () => {
|
||||
const { safeResolvePath, isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/file.txt");
|
||||
vi.mocked(isSystemFile).mockReturnValue(false);
|
||||
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "file.txt" }),
|
||||
});
|
||||
const res = await DELETE(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 403 for system file", async () => {
|
||||
const { isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(isSystemFile).mockReturnValue(true);
|
||||
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: ".object.yaml" }),
|
||||
});
|
||||
const res = await DELETE(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 when file not found", async () => {
|
||||
const { safeResolvePath, isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(isSystemFile).mockReturnValue(false);
|
||||
vi.mocked(safeResolvePath).mockReturnValue(null);
|
||||
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "nonexistent.txt" }),
|
||||
});
|
||||
const res = await DELETE(req);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 for missing path", async () => {
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await DELETE(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON body", async () => {
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
const res = await DELETE(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/mkdir ──────────────────────────────────
|
||||
|
||||
describe("POST /api/workspace/mkdir", () => {
|
||||
it("creates directory successfully", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue("/ws/new-folder");
|
||||
|
||||
const { POST } = await import("./mkdir/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "new-folder" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 for missing path", async () => {
|
||||
const { POST } = await import("./mkdir/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for traversal attempt", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue(null);
|
||||
|
||||
const { POST } = await import("./mkdir/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "../../etc" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
392
apps/web/app/api/workspace/objects.test.ts
Normal file
392
apps/web/app/api/workspace/objects.test.ts
Normal file
@ -0,0 +1,392 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:child_process
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
}));
|
||||
|
||||
// Mock workspace
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
duckdbPath: vi.fn(() => null),
|
||||
duckdbQueryOnFile: vi.fn(() => []),
|
||||
duckdbExecOnFile: vi.fn(() => true),
|
||||
findDuckDBForObject: vi.fn(() => null),
|
||||
parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
describe("Workspace Objects API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
duckdbPath: vi.fn(() => null),
|
||||
duckdbQueryOnFile: vi.fn(() => []),
|
||||
duckdbExecOnFile: vi.fn(() => true),
|
||||
findDuckDBForObject: vi.fn(() => null),
|
||||
parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/objects/[name] ──────────────────────────
|
||||
|
||||
describe("GET /api/workspace/objects/[name]", () => {
|
||||
it("returns 503 when DuckDB CLI not installed", async () => {
|
||||
const { resolveDuckdbBin } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveDuckdbBin).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./objects/[name]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/workspace/objects/bad-name!"),
|
||||
{ params: Promise.resolve({ name: "bad-name!" }) },
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid object name (when duckdb available)", async () => {
|
||||
const { resolveDuckdbBin } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
|
||||
|
||||
const { GET } = await import("./objects/[name]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/workspace/objects/bad!name"),
|
||||
{ params: Promise.resolve({ name: "bad!name" }) },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when object not found", async () => {
|
||||
const { findDuckDBForObject, resolveDuckdbBin, duckdbPath: mockDuckdbPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
vi.mocked(mockDuckdbPath).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./objects/[name]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/workspace/objects/nonexistent"),
|
||||
{ params: Promise.resolve({ name: "nonexistent" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns object schema and entries when found", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile, resolveDuckdbBin, discoverDuckDBPaths } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
|
||||
vi.mocked(discoverDuckDBPaths).mockReturnValue(["/ws/workspace.duckdb"]);
|
||||
|
||||
// Mock different queries with a call counter
|
||||
let queryCall = 0;
|
||||
vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
|
||||
queryCall++;
|
||||
if (queryCall === 1) {
|
||||
// Object row
|
||||
return [{ id: "obj1", name: "leads", description: "Leads object", icon: "star" }];
|
||||
}
|
||||
if (queryCall === 2) {
|
||||
// Fields
|
||||
return [
|
||||
{ id: "f1", name: "name", type: "text", sort_order: 0 },
|
||||
{ id: "f2", name: "status", type: "enum", sort_order: 1, enum_values: '["New","Active"]' },
|
||||
];
|
||||
}
|
||||
if (queryCall === 3) {
|
||||
// Statuses
|
||||
return [];
|
||||
}
|
||||
// Entries and subsequent queries
|
||||
return [];
|
||||
});
|
||||
|
||||
const { GET } = await import("./objects/[name]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/workspace/objects/leads"),
|
||||
{ params: Promise.resolve({ name: "leads" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.object).toBeDefined();
|
||||
expect(json.fields).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts underscored names", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./objects/[name]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/workspace/objects/my_object"),
|
||||
{ params: Promise.resolve({ name: "my_object" }) },
|
||||
);
|
||||
// 404 because findDuckDBForObject returns null, but name validation passes
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/objects/[name]/entries ─────────────────
|
||||
|
||||
describe("POST /api/workspace/objects/[name]/entries", () => {
|
||||
it("returns 400 for invalid object name", async () => {
|
||||
const { POST } = await import("./objects/[name]/entries/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/objects/bad!/entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "bad!" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when DuckDB not found", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
|
||||
const { POST } = await import("./objects/[name]/entries/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/objects/leads/entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("creates entry successfully", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
|
||||
let queryCall = 0;
|
||||
vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
|
||||
queryCall++;
|
||||
if (queryCall === 1) {return [{ id: "obj1" }];} // object lookup
|
||||
if (queryCall === 2) {return [{ id: "new-entry-uuid" }];} // uuid generation
|
||||
return [];
|
||||
});
|
||||
vi.mocked(duckdbExecOnFile).mockReturnValue(true);
|
||||
|
||||
const { POST } = await import("./objects/[name]/entries/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/objects/leads/entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: { name: "Acme Corp" } }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
|
||||
expect(res.status).toBe(201);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
expect(json.entryId).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 404 when object not found in DB", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([]); // object not found
|
||||
|
||||
const { POST } = await import("./objects/[name]/entries/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/objects/missing/entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "missing" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/objects/[name]/entries/[id] ─────────────
|
||||
|
||||
describe("GET /api/workspace/objects/[name]/entries/[id]", () => {
|
||||
it("returns 400 for invalid object name", async () => {
|
||||
const { GET } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ name: "bad!", id: "123" }) },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when DuckDB not found", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ name: "leads", id: "123" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns entry details when found", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
|
||||
let queryCall = 0;
|
||||
vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
|
||||
queryCall++;
|
||||
if (queryCall === 1) {return [{ id: "obj1" }];} // object
|
||||
if (queryCall === 2) {return [{ id: "f1", name: "name", type: "text" }];} // fields
|
||||
if (queryCall === 3) {return [{ entry_id: "e1", field_name: "name", value: "Acme", created_at: "2025-01-01", updated_at: "2025-01-01" }];} // EAV
|
||||
return [];
|
||||
});
|
||||
|
||||
const { GET } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ name: "leads", id: "e1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.entry).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PATCH /api/workspace/objects/[name]/entries/[id] ───────────
|
||||
|
||||
describe("PATCH /api/workspace/objects/[name]/entries/[id]", () => {
|
||||
it("returns 400 for invalid object name", async () => {
|
||||
const { PATCH } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: {} }),
|
||||
});
|
||||
const res = await PATCH(req, { params: Promise.resolve({ name: "bad!", id: "123" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when DuckDB not found", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
|
||||
const { PATCH } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: { name: "Updated" } }),
|
||||
});
|
||||
const res = await PATCH(req, { params: Promise.resolve({ name: "leads", id: "e1" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("updates entry fields", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
|
||||
let queryCall = 0;
|
||||
vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
|
||||
queryCall++;
|
||||
if (queryCall === 1) {return [{ id: "obj1" }];} // object
|
||||
if (queryCall === 2) {return [{ id: "f1", name: "name", type: "text" }];} // fields
|
||||
return [];
|
||||
});
|
||||
vi.mocked(duckdbExecOnFile).mockReturnValue(true);
|
||||
|
||||
const { PATCH } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: { name: "Updated Corp" } }),
|
||||
});
|
||||
const res = await PATCH(req, { params: Promise.resolve({ name: "leads", id: "e1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /api/workspace/objects/[name]/entries/[id] ──────────
|
||||
|
||||
describe("DELETE /api/workspace/objects/[name]/entries/[id]", () => {
|
||||
it("returns 400 for invalid object name", async () => {
|
||||
const { DELETE } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost", { method: "DELETE" }),
|
||||
{ params: Promise.resolve({ name: "bad!", id: "123" }) },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when DuckDB not found", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
|
||||
const { DELETE } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost", { method: "DELETE" }),
|
||||
{ params: Promise.resolve({ name: "leads", id: "e1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("deletes entry successfully", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: "obj1" }]);
|
||||
vi.mocked(duckdbExecOnFile).mockReturnValue(true);
|
||||
|
||||
const { DELETE } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost", { method: "DELETE" }),
|
||||
{ params: Promise.resolve({ name: "leads", id: "e1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/objects/[name]/entries/bulk-delete ─────
|
||||
|
||||
describe("POST /api/workspace/objects/[name]/entries/bulk-delete", () => {
|
||||
it("returns 400 for invalid object name", async () => {
|
||||
const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: ["e1"] }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "bad!" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for empty entryIds", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
|
||||
const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ entryIds: [] }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("deletes multiple entries", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: "obj1" }]);
|
||||
vi.mocked(duckdbExecOnFile).mockReturnValue(true);
|
||||
|
||||
const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ entryIds: ["e1", "e2", "e3"] }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
233
apps/web/app/api/workspace/tree-browse.test.ts
Normal file
233
apps/web/app/api/workspace/tree-browse.test.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { Dirent } from "node:fs";
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock("node:fs", () => ({
|
||||
readdirSync: vi.fn(() => []),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
existsSync: vi.fn(() => false),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => false, size: 100 })),
|
||||
}));
|
||||
|
||||
// Mock node:os
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
// Mock workspace
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
parseSimpleYaml: vi.fn(() => ({})),
|
||||
duckdbQueryAll: vi.fn(() => []),
|
||||
duckdbQueryAllAsync: vi.fn(async () => []),
|
||||
isDatabaseFile: vi.fn(() => false),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
safeResolvePath: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
function makeDirent(name: string, isDir: boolean): Dirent {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => isDir,
|
||||
isFile: () => !isDir,
|
||||
isBlockDevice: () => false,
|
||||
isCharacterDevice: () => false,
|
||||
isFIFO: () => false,
|
||||
isSocket: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
path: "",
|
||||
parentPath: "",
|
||||
} as Dirent;
|
||||
}
|
||||
|
||||
describe("Workspace Tree & Browse API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:fs", () => ({
|
||||
readdirSync: vi.fn(() => []),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
existsSync: vi.fn(() => false),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => false, size: 100 })),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
parseSimpleYaml: vi.fn(() => ({})),
|
||||
duckdbQueryAll: vi.fn(() => []),
|
||||
duckdbQueryAllAsync: vi.fn(async () => []),
|
||||
isDatabaseFile: vi.fn(() => false),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
safeResolvePath: vi.fn(() => null),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/tree ────────────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/tree", () => {
|
||||
it("returns tree with exists=false when no workspace root", async () => {
|
||||
const { GET } = await import("./tree/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.exists).toBe(false);
|
||||
expect(json.tree).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns tree with workspace files", async () => {
|
||||
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
|
||||
const { readdirSync: mockReaddir, existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockImplementation((dir) => {
|
||||
if (String(dir) === "/ws") {
|
||||
return [
|
||||
makeDirent("knowledge", true),
|
||||
makeDirent("readme.md", false),
|
||||
] as unknown as Dirent[];
|
||||
}
|
||||
return [] as unknown as Dirent[];
|
||||
});
|
||||
|
||||
const { GET } = await import("./tree/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.exists).toBe(true);
|
||||
expect(json.tree.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes workspaceRoot in response", async () => {
|
||||
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
|
||||
const { existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
|
||||
const { GET } = await import("./tree/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.workspaceRoot).toBe("/ws");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/browse ──────────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/browse", () => {
|
||||
it("returns directory listing", async () => {
|
||||
const { existsSync: mockExists, readdirSync: mockReaddir, statSync: mockStat } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockReturnValue([
|
||||
makeDirent("file.txt", false),
|
||||
makeDirent("subfolder", true),
|
||||
] as unknown as Dirent[]);
|
||||
vi.mocked(mockStat).mockReturnValue({ isDirectory: () => false, size: 100 } as never);
|
||||
|
||||
const { GET } = await import("./browse/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/browse?dir=/tmp/test");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.entries).toBeDefined();
|
||||
expect(json.currentDir).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns parentDir for nested directories", async () => {
|
||||
const { existsSync: mockExists, readdirSync: mockReaddir, statSync: mockStat } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockReturnValue([]);
|
||||
vi.mocked(mockStat).mockReturnValue({ isDirectory: () => true, size: 0 } as never);
|
||||
|
||||
const { GET } = await import("./browse/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/browse?dir=/tmp/test/sub");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.parentDir).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/suggest-files ────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/suggest-files", () => {
|
||||
it("returns suggestions when workspace exists", async () => {
|
||||
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
|
||||
const { existsSync: mockExists, readdirSync: mockReaddir } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockReturnValue([
|
||||
makeDirent("doc.md", false),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
const { GET } = await import("./suggest-files/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/suggest-files?q=doc");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.items).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/context ──────────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/context", () => {
|
||||
it("returns exists=false when no workspace root", async () => {
|
||||
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./context/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.exists).toBe(false);
|
||||
});
|
||||
|
||||
it("returns context when workspace_context.yaml exists", async () => {
|
||||
const { resolveWorkspaceRoot, parseSimpleYaml } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
|
||||
vi.mocked(parseSimpleYaml).mockReturnValue({ org_name: "Acme", org_slug: "acme" });
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockReturnValue("org_name: Acme" as never);
|
||||
|
||||
const { GET } = await import("./context/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.exists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/search-index ─────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/search-index", () => {
|
||||
it("returns empty items when no workspace", async () => {
|
||||
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./search-index/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns file items from workspace tree", async () => {
|
||||
const { resolveWorkspaceRoot, duckdbQueryAll } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
|
||||
vi.mocked(duckdbQueryAll).mockReturnValue([]);
|
||||
const { existsSync: mockExists, readdirSync: mockReaddir } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockImplementation((dir) => {
|
||||
if (String(dir) === "/ws") {
|
||||
return [makeDirent("readme.md", false)] as unknown as Dirent[];
|
||||
}
|
||||
return [] as unknown as Dirent[];
|
||||
});
|
||||
|
||||
const { GET } = await import("./search-index/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.items.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -621,6 +621,87 @@ describe("active-runs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── multiple concurrent runs ─────────────────────────────────────
|
||||
|
||||
describe("multiple concurrent runs", () => {
|
||||
it("tracks multiple sessions independently", async () => {
|
||||
const { startRun, hasActiveRun, getActiveRun } = await setup();
|
||||
|
||||
startRun({ sessionId: "s-a", message: "first", agentSessionId: "s-a" });
|
||||
startRun({ sessionId: "s-b", message: "second", agentSessionId: "s-b" });
|
||||
|
||||
expect(hasActiveRun("s-a")).toBe(true);
|
||||
expect(hasActiveRun("s-b")).toBe(true);
|
||||
expect(getActiveRun("s-a")?.status).toBe("running");
|
||||
expect(getActiveRun("s-b")?.status).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
// ── tool result events ───────────────────────────────────────────
|
||||
|
||||
describe("tool result events", () => {
|
||||
it("emits tool-result events for completed tool calls", async () => {
|
||||
const { child, startRun, subscribeToRun } = await setup();
|
||||
|
||||
const events: SseEvent[] = [];
|
||||
|
||||
startRun({ sessionId: "s-tr", message: "use tool", agentSessionId: "s-tr" });
|
||||
|
||||
subscribeToRun("s-tr", (event) => {
|
||||
if (event) {events.push(event);}
|
||||
}, { replay: false });
|
||||
|
||||
// Emit tool start
|
||||
child._writeLine({
|
||||
event: "agent",
|
||||
stream: "tool",
|
||||
data: { phase: "start", toolCallId: "tc-1", name: "search", args: { q: "test" } },
|
||||
});
|
||||
|
||||
// Emit tool result
|
||||
child._writeLine({
|
||||
event: "agent",
|
||||
stream: "tool",
|
||||
data: { phase: "result", toolCallId: "tc-1", result: "found 3 results" },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(events.some((e) => e.type === "tool-input-start" && e.toolCallId === "tc-1")).toBe(true);
|
||||
|
||||
child.stdout.end();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
child._emit("close", 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── stderr handling ──────────────────────────────────────────────
|
||||
|
||||
describe("stderr handling", () => {
|
||||
it("captures stderr output for error reporting", async () => {
|
||||
const { child, startRun, subscribeToRun } = await setup();
|
||||
|
||||
const events: SseEvent[] = [];
|
||||
|
||||
startRun({ sessionId: "s-stderr", message: "fail", agentSessionId: "s-stderr" });
|
||||
|
||||
subscribeToRun("s-stderr", (event) => {
|
||||
if (event) {events.push(event);}
|
||||
}, { replay: false });
|
||||
|
||||
child._writeStderr("Error: something went wrong\n");
|
||||
|
||||
child.stdout.end();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
child._emit("close", 1);
|
||||
|
||||
// Should have an error message mentioning stderr content
|
||||
expect(events.some((e) =>
|
||||
e.type === "text-delta" && typeof e.delta === "string",
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── lifecycle events ──────────────────────────────────────────────
|
||||
|
||||
describe("lifecycle events", () => {
|
||||
|
||||
@ -309,5 +309,80 @@ describe("agent-runner", () => {
|
||||
);
|
||||
expect(parseErrorFromStderr("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extracts first error line from multi-line stderr", async () => {
|
||||
const { parseErrorFromStderr } = await import(
|
||||
"./agent-runner.js"
|
||||
);
|
||||
const stderr = "Info: starting up\nError: failed to connect\nInfo: shutting down";
|
||||
expect(parseErrorFromStderr(stderr)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns undefined for non-error stderr content", async () => {
|
||||
const { parseErrorFromStderr } = await import(
|
||||
"./agent-runner.js"
|
||||
);
|
||||
const stderr = "Warning: deprecated feature\nInfo: all good";
|
||||
// No line contains 'error' keyword
|
||||
const result = parseErrorFromStderr(stderr);
|
||||
// Implementation checks for 'error' (case-insensitive)
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseErrorBody ──────────────────────────────────────────────
|
||||
|
||||
describe("parseErrorBody", () => {
|
||||
it("extracts error message from JSON error body", async () => {
|
||||
const { parseErrorBody } = await import("./agent-runner.js");
|
||||
const body = '{"error":{"message":"Something failed"}}';
|
||||
const result = parseErrorBody(body);
|
||||
expect(result).toBe("Something failed");
|
||||
});
|
||||
|
||||
it("returns raw string for non-JSON body", async () => {
|
||||
const { parseErrorBody } = await import("./agent-runner.js");
|
||||
expect(parseErrorBody("plain text error")).toBe("plain text error");
|
||||
});
|
||||
|
||||
it("returns raw string for empty body", async () => {
|
||||
const { parseErrorBody } = await import("./agent-runner.js");
|
||||
expect(parseErrorBody("")).toBe("");
|
||||
});
|
||||
|
||||
it("extracts message from nested error object", async () => {
|
||||
const { parseErrorBody } = await import("./agent-runner.js");
|
||||
const body = '{"error":{"message":"Rate limit","type":"rate_limit_error"}}';
|
||||
const result = parseErrorBody(body);
|
||||
expect(result).toBe("Rate limit");
|
||||
});
|
||||
});
|
||||
|
||||
// ── spawnAgentProcess with file context ──────────────────────────
|
||||
|
||||
describe("spawnAgentProcess (additional)", () => {
|
||||
it("includes file context flags when filePath is set", async () => {
|
||||
process.env.OPENCLAW_ROOT = "/pkg";
|
||||
|
||||
const { existsSync: mockExists } = await import("node:fs");
|
||||
const { spawn: mockSpawn } = await import("node:child_process");
|
||||
|
||||
vi.mocked(mockExists).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === "/pkg" || s === join("/pkg", "openclaw.mjs");
|
||||
});
|
||||
|
||||
const child = mockChildProcess();
|
||||
vi.mocked(mockSpawn).mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const { spawnAgentProcess } = await import("./agent-runner.js");
|
||||
spawnAgentProcess("analyze this file", "session-1", "knowledge/doc.md");
|
||||
|
||||
expect(vi.mocked(mockSpawn)).toHaveBeenCalledWith(
|
||||
"node",
|
||||
expect.arrayContaining(["--message"]),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
196
apps/web/lib/diff-blocks.test.ts
Normal file
196
apps/web/lib/diff-blocks.test.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { splitDiffBlocks, hasDiffBlocks } from "./diff-blocks";
|
||||
|
||||
// ─── hasDiffBlocks ─────────────────────────────────────────────────
|
||||
|
||||
describe("hasDiffBlocks", () => {
|
||||
it("returns true when diff block is present", () => {
|
||||
expect(hasDiffBlocks("```diff\n-old\n+new\n```")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for plain text", () => {
|
||||
expect(hasDiffBlocks("Hello world")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for regular code block", () => {
|
||||
expect(hasDiffBlocks("```js\nconst x = 1;\n```")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for partial match (streaming content)", () => {
|
||||
expect(hasDiffBlocks("Some text then ```diff")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for multiple diff blocks", () => {
|
||||
expect(hasDiffBlocks("```diff\n-a\n```\ntext\n```diff\n+b\n```")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(hasDiffBlocks("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for 'diff' without backtick fence", () => {
|
||||
expect(hasDiffBlocks("This is a diff of two files")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── splitDiffBlocks ───────────────────────────────────────────────
|
||||
|
||||
describe("splitDiffBlocks", () => {
|
||||
it("returns text segment for plain text with no blocks", () => {
|
||||
const result = splitDiffBlocks("Hello world");
|
||||
expect(result).toEqual([{ type: "text", text: "Hello world" }]);
|
||||
});
|
||||
|
||||
it("returns empty array for whitespace-only text", () => {
|
||||
expect(splitDiffBlocks(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses a single diff block", () => {
|
||||
const text = "```diff\n-old line\n+new line\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("diff-artifact");
|
||||
if (result[0].type === "diff-artifact") {
|
||||
expect(result[0].diff).toBe("-old line\n+new line");
|
||||
}
|
||||
});
|
||||
|
||||
it("splits text before and after a diff block", () => {
|
||||
const text = "Before text\n\n```diff\n-old\n+new\n```\n\nAfter text";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({ type: "text", text: "Before text\n\n" });
|
||||
expect(result[1].type).toBe("diff-artifact");
|
||||
expect(result[2]).toEqual({ type: "text", text: "\n\nAfter text" });
|
||||
});
|
||||
|
||||
it("handles multiple diff blocks", () => {
|
||||
const text = "First:\n```diff\n-a\n+b\n```\nSecond:\n```diff\n-c\n+d\n```\nDone.";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0].type).toBe("text");
|
||||
expect(result[1].type).toBe("diff-artifact");
|
||||
expect(result[2].type).toBe("text");
|
||||
expect(result[3].type).toBe("diff-artifact");
|
||||
expect(result[4].type).toBe("text");
|
||||
});
|
||||
|
||||
it("handles diff block at the very beginning", () => {
|
||||
const text = "```diff\n-x\n+y\n```\nSome text after.";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].type).toBe("diff-artifact");
|
||||
expect(result[1].type).toBe("text");
|
||||
});
|
||||
|
||||
it("handles diff block at the very end", () => {
|
||||
const text = "Some text before\n```diff\n-x\n+y\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].type).toBe("text");
|
||||
expect(result[1].type).toBe("diff-artifact");
|
||||
});
|
||||
|
||||
it("handles empty diff block (becomes text)", () => {
|
||||
const text = "```diff\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("text");
|
||||
expect(result[0]).toEqual({ type: "text", text: "```diff\n```" });
|
||||
});
|
||||
|
||||
it("handles complex unified diff format", () => {
|
||||
const diff = [
|
||||
"--- a/file.ts",
|
||||
"+++ b/file.ts",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" line 1",
|
||||
"-old line 2",
|
||||
"+new line 2",
|
||||
" line 3",
|
||||
].join("\n");
|
||||
const text = `\`\`\`diff\n${diff}\n\`\`\``;
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("diff-artifact");
|
||||
if (result[0].type === "diff-artifact") {
|
||||
expect(result[0].diff).toContain("--- a/file.ts");
|
||||
expect(result[0].diff).toContain("+++ b/file.ts");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles diff with special characters", () => {
|
||||
const text = '```diff\n-const x = "hello";\n+const x = "world";\n```';
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("diff-artifact");
|
||||
});
|
||||
|
||||
it("does not match regular code blocks", () => {
|
||||
const text = "```js\nconst x = 1;\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("text");
|
||||
});
|
||||
|
||||
it("handles only diff block with no surrounding text", () => {
|
||||
const text = "```diff\n+added\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("diff-artifact");
|
||||
if (result[0].type === "diff-artifact") {
|
||||
expect(result[0].diff).toBe("+added");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles diff with whitespace-only before (trimmed away)", () => {
|
||||
const text = " \n```diff\n+added\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("diff-artifact");
|
||||
});
|
||||
|
||||
it("handles diff with whitespace after language tag", () => {
|
||||
const text = "```diff \n-removed\n+added\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("diff-artifact");
|
||||
});
|
||||
|
||||
it("handles consecutive diff blocks with no text between", () => {
|
||||
const text = "```diff\n-a\n```\n```diff\n+b\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
// The \n between blocks isn't trimmed, so we get 2 diff artifacts
|
||||
const diffArtifacts = result.filter((s) => s.type === "diff-artifact");
|
||||
expect(diffArtifacts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("handles diff block with deletion-only content", () => {
|
||||
const text = "```diff\n-line1\n-line2\n-line3\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
if (result[0].type === "diff-artifact") {
|
||||
expect(result[0].diff).toContain("-line1");
|
||||
expect(result[0].diff).toContain("-line3");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles diff block with addition-only content", () => {
|
||||
const text = "```diff\n+new1\n+new2\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
if (result[0].type === "diff-artifact") {
|
||||
expect(result[0].diff).toContain("+new1");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves context lines in diff", () => {
|
||||
const text = "```diff\n context\n-removed\n+added\n context2\n```";
|
||||
const result = splitDiffBlocks(text);
|
||||
expect(result).toHaveLength(1);
|
||||
if (result[0].type === "diff-artifact") {
|
||||
expect(result[0].diff).toContain(" context");
|
||||
expect(result[0].diff).toContain(" context2");
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -309,4 +309,83 @@ describe("checkSqlSafety", () => {
|
||||
// The SQL starts with SELECT, so it should be allowed
|
||||
expect(checkSqlSafety('SELECT "delete_count", "update_time" FROM v_stats')).toBeNull();
|
||||
});
|
||||
|
||||
it("allows GRANT (not in forbidden list, only checks start keyword)", () => {
|
||||
// checkSqlSafety only blocks specific forbidden start keywords
|
||||
expect(checkSqlSafety("GRANT ALL ON users TO admin")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows empty SQL (no forbidden keyword match)", () => {
|
||||
// Empty string doesn't start with any forbidden keyword
|
||||
expect(checkSqlSafety("")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows EXPLAIN SELECT (not in forbidden list)", () => {
|
||||
expect(checkSqlSafety("EXPLAIN SELECT * FROM users")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Edge cases for buildFilterClauses ───
|
||||
|
||||
describe("buildFilterClauses (edge cases)", () => {
|
||||
it("handles unicode in select filter values", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "s", column: "Name", value: { type: "select", value: "日本語" } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
expect(clauses[0]).toBe(`"Name" = '日本語'`);
|
||||
});
|
||||
|
||||
it("handles very long column names", () => {
|
||||
const longName = "a".repeat(200);
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "s", column: longName, value: { type: "select", value: "x" } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
expect(clauses[0]).toContain(longName);
|
||||
});
|
||||
|
||||
it("handles NaN min in number filter", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "n", column: "Amount", value: { type: "number", min: NaN } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
// NaN is !== undefined so it should produce a clause
|
||||
expect(clauses).toHaveLength(1);
|
||||
expect(clauses[0]).toContain("NaN");
|
||||
});
|
||||
|
||||
it("handles max of 0 in number filter", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "n", column: "Score", value: { type: "number", max: 0 } },
|
||||
];
|
||||
expect(buildFilterClauses(filters)).toEqual([`CAST("Score" AS NUMERIC) <= 0`]);
|
||||
});
|
||||
|
||||
it("handles multiSelect with single-quote values", () => {
|
||||
const filters: FilterEntry[] = [
|
||||
{ id: "m", column: "Tag", value: { type: "multiSelect", values: ["it's", "they're"] } },
|
||||
];
|
||||
const clauses = buildFilterClauses(filters);
|
||||
expect(clauses[0]).toBe(`"Tag" IN ('it''s', 'they''re')`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Edge cases for injectFilters ───
|
||||
|
||||
describe("injectFilters (edge cases)", () => {
|
||||
it("handles SQL with multiple semicolons", () => {
|
||||
const sql = "SELECT * FROM t;;";
|
||||
const clauses = [`"x" = '1'`];
|
||||
const result = injectFilters(sql, clauses);
|
||||
expect(result).toContain("__report_data");
|
||||
});
|
||||
|
||||
it("handles SQL with nested CTE", () => {
|
||||
const sql = "WITH a AS (WITH b AS (SELECT 1) SELECT * FROM b) SELECT * FROM a";
|
||||
const clauses = [`"x" = '1'`];
|
||||
const result = injectFilters(sql, clauses);
|
||||
expect(result).toContain("__report_data");
|
||||
expect(result).toContain("WITH a AS");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isReportFile,
|
||||
isCodeFile,
|
||||
classifyFileType,
|
||||
reportTitleToSlug,
|
||||
panelColSpan,
|
||||
@ -37,6 +38,66 @@ describe("isReportFile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isCodeFile ───
|
||||
|
||||
describe("isCodeFile", () => {
|
||||
it("returns true for .ts files", () => {
|
||||
expect(isCodeFile("index.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for .tsx files", () => {
|
||||
expect(isCodeFile("component.tsx")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for .py files", () => {
|
||||
expect(isCodeFile("script.py")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for .go files", () => {
|
||||
expect(isCodeFile("main.go")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for .rs files", () => {
|
||||
expect(isCodeFile("lib.rs")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for .sql files", () => {
|
||||
expect(isCodeFile("query.sql")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for .yaml files", () => {
|
||||
expect(isCodeFile("config.yaml")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for .json files", () => {
|
||||
expect(isCodeFile("data.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for .sh files", () => {
|
||||
expect(isCodeFile("deploy.sh")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for .md files", () => {
|
||||
expect(isCodeFile("readme.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for .txt files", () => {
|
||||
expect(isCodeFile("notes.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for .png files", () => {
|
||||
expect(isCodeFile("image.png")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for Makefile (makefile is a code extension)", () => {
|
||||
expect(isCodeFile("Makefile")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for files with non-code extension", () => {
|
||||
expect(isCodeFile("archive.zip")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── classifyFileType ───
|
||||
|
||||
describe("classifyFileType", () => {
|
||||
@ -197,6 +258,22 @@ describe("formatChartValue", () => {
|
||||
it("formats 999 as integer", () => {
|
||||
expect(formatChartValue(999)).toBe("999");
|
||||
});
|
||||
|
||||
it("formats object as JSON string", () => {
|
||||
expect(formatChartValue({ key: "val" })).toBe('{"key":"val"}');
|
||||
});
|
||||
|
||||
it("formats array as JSON string", () => {
|
||||
expect(formatChartValue([1, 2, 3])).toBe("[1,2,3]");
|
||||
});
|
||||
|
||||
it("formats negative float", () => {
|
||||
expect(formatChartValue(-3.14)).toBe("-3.14");
|
||||
});
|
||||
|
||||
it("formats very large number", () => {
|
||||
expect(formatChartValue(999_999_999)).toBe("1000.0M");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatChartLabel ───
|
||||
|
||||
50
apps/web/lib/test-helpers.ts
Normal file
50
apps/web/lib/test-helpers.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Shared test utilities for apps/web tests.
|
||||
* Provides mock Request builders, workspace root helpers, and common fixtures.
|
||||
*/
|
||||
|
||||
/** Build a mock Request with JSON body. */
|
||||
export function mockRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown,
|
||||
): Request {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
return new Request(`http://localhost${url}`, init);
|
||||
}
|
||||
|
||||
/** Build a GET Request with query params. */
|
||||
export function mockGet(url: string, params?: Record<string, string>): Request {
|
||||
const u = new URL(`http://localhost${url}`);
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
u.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
return new Request(u.toString(), { method: "GET" });
|
||||
}
|
||||
|
||||
/** Extract JSON body from a Response, asserting status. */
|
||||
export async function jsonResponse<T = unknown>(
|
||||
res: Response,
|
||||
expectedStatus?: number,
|
||||
): Promise<T> {
|
||||
if (expectedStatus !== undefined && res.status !== expectedStatus) {
|
||||
const text = await res.text().catch(() => "(unreadable)");
|
||||
throw new Error(
|
||||
`Expected status ${expectedStatus}, got ${res.status}: ${text}`,
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Build a mock Next.js route context with params. */
|
||||
export function mockRouteContext(params: Record<string, string>) {
|
||||
return { params: Promise.resolve(params) };
|
||||
}
|
||||
228
apps/web/lib/workspace-links.test.ts
Normal file
228
apps/web/lib/workspace-links.test.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildEntryLink,
|
||||
buildFileLink,
|
||||
parseWorkspaceLink,
|
||||
isWorkspaceLink,
|
||||
isInternalLink,
|
||||
isEntryLink,
|
||||
} from "./workspace-links";
|
||||
|
||||
// ─── buildEntryLink ────────────────────────────────────────────────
|
||||
|
||||
describe("buildEntryLink", () => {
|
||||
it("builds a basic entry link", () => {
|
||||
expect(buildEntryLink("leads", "abc123")).toBe("/workspace?entry=leads:abc123");
|
||||
});
|
||||
|
||||
it("encodes special characters in object name", () => {
|
||||
const result = buildEntryLink("my objects", "id1");
|
||||
expect(result).toContain("my%20objects");
|
||||
expect(result).toContain("id1");
|
||||
});
|
||||
|
||||
it("encodes special characters in entry ID", () => {
|
||||
const result = buildEntryLink("leads", "id/with/slashes");
|
||||
expect(result).toContain("id%2Fwith%2Fslashes");
|
||||
});
|
||||
|
||||
it("handles empty object name", () => {
|
||||
const result = buildEntryLink("", "id1");
|
||||
expect(result).toBe("/workspace?entry=:id1");
|
||||
});
|
||||
|
||||
it("handles unicode characters", () => {
|
||||
const result = buildEntryLink("対象", "エントリ");
|
||||
expect(result).toContain("/workspace?entry=");
|
||||
// Should decode back correctly
|
||||
const url = new URL(result, "http://localhost");
|
||||
expect(url.searchParams.get("entry")).toBe("対象:エントリ");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildFileLink ────────────────────────────────────────────────
|
||||
|
||||
describe("buildFileLink", () => {
|
||||
it("builds a basic file link", () => {
|
||||
expect(buildFileLink("knowledge/doc.md")).toBe("/workspace?path=knowledge%2Fdoc.md");
|
||||
});
|
||||
|
||||
it("builds link for nested path", () => {
|
||||
const result = buildFileLink("a/b/c/d.txt");
|
||||
const url = new URL(result, "http://localhost");
|
||||
expect(url.searchParams.get("path")).toBe("a/b/c/d.txt");
|
||||
});
|
||||
|
||||
it("handles spaces in path", () => {
|
||||
const result = buildFileLink("my docs/file name.md");
|
||||
const url = new URL(result, "http://localhost");
|
||||
expect(url.searchParams.get("path")).toBe("my docs/file name.md");
|
||||
});
|
||||
|
||||
it("handles special characters", () => {
|
||||
const result = buildFileLink("notes & ideas/doc (1).md");
|
||||
const url = new URL(result, "http://localhost");
|
||||
expect(url.searchParams.get("path")).toBe("notes & ideas/doc (1).md");
|
||||
});
|
||||
|
||||
it("handles empty path", () => {
|
||||
expect(buildFileLink("")).toBe("/workspace?path=");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseWorkspaceLink ───────────────────────────────────────────
|
||||
|
||||
describe("parseWorkspaceLink", () => {
|
||||
it("parses file link from path param", () => {
|
||||
const result = parseWorkspaceLink("/workspace?path=knowledge/doc.md");
|
||||
expect(result).toEqual({ kind: "file", path: "knowledge/doc.md" });
|
||||
});
|
||||
|
||||
it("parses entry link from entry param", () => {
|
||||
const result = parseWorkspaceLink("/workspace?entry=leads:abc123");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "leads", entryId: "abc123" });
|
||||
});
|
||||
|
||||
it("parses entry link from full URL", () => {
|
||||
const result = parseWorkspaceLink("http://localhost:3100/workspace?entry=deals:xyz");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "deals", entryId: "xyz" });
|
||||
});
|
||||
|
||||
it("parses file link from full URL", () => {
|
||||
const result = parseWorkspaceLink("http://localhost:3100/workspace?path=readme.md");
|
||||
expect(result).toEqual({ kind: "file", path: "readme.md" });
|
||||
});
|
||||
|
||||
it("parses legacy @entry/ format", () => {
|
||||
const result = parseWorkspaceLink("@entry/leads/abc123");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "leads", entryId: "abc123" });
|
||||
});
|
||||
|
||||
it("returns null for invalid URL", () => {
|
||||
expect(parseWorkspaceLink("not a url ://bad")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no params present", () => {
|
||||
expect(parseWorkspaceLink("/workspace")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for hash-only link", () => {
|
||||
expect(parseWorkspaceLink("/workspace#section")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for entry param without colon", () => {
|
||||
expect(parseWorkspaceLink("/workspace?entry=nocolon")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles deeply nested file path", () => {
|
||||
const result = parseWorkspaceLink("/workspace?path=a/b/c/d/e/f.txt");
|
||||
expect(result).toEqual({ kind: "file", path: "a/b/c/d/e/f.txt" });
|
||||
});
|
||||
|
||||
it("handles encoded characters in path", () => {
|
||||
const result = parseWorkspaceLink("/workspace?path=my%20docs%2Ffile.md");
|
||||
expect(result).toEqual({ kind: "file", path: "my docs/file.md" });
|
||||
});
|
||||
|
||||
it("returns null for non-workspace URL", () => {
|
||||
expect(parseWorkspaceLink("https://google.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("entry param takes priority over path param", () => {
|
||||
const result = parseWorkspaceLink("/workspace?entry=obj:id&path=file.md");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "obj", entryId: "id" });
|
||||
});
|
||||
|
||||
it("handles entry with colon in ID", () => {
|
||||
const result = parseWorkspaceLink("/workspace?entry=obj:id:with:colons");
|
||||
expect(result).toEqual({ kind: "entry", objectName: "obj", entryId: "id:with:colons" });
|
||||
});
|
||||
|
||||
it("returns null for legacy @entry with no slash after object name", () => {
|
||||
expect(parseWorkspaceLink("@entry/objectonly")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isWorkspaceLink ──────────────────────────────────────────────
|
||||
|
||||
describe("isWorkspaceLink", () => {
|
||||
it("returns true for /workspace?path=...", () => {
|
||||
expect(isWorkspaceLink("/workspace?path=doc.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for /workspace#...", () => {
|
||||
expect(isWorkspaceLink("/workspace#section")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for /workspace alone", () => {
|
||||
expect(isWorkspaceLink("/workspace")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for @entry/ format", () => {
|
||||
expect(isWorkspaceLink("@entry/leads/abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for external URL", () => {
|
||||
expect(isWorkspaceLink("https://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for random path", () => {
|
||||
expect(isWorkspaceLink("/other-page")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(isWorkspaceLink("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isInternalLink ───────────────────────────────────────────────
|
||||
|
||||
describe("isInternalLink", () => {
|
||||
it("returns false for http:// URLs", () => {
|
||||
expect(isInternalLink("http://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for https:// URLs", () => {
|
||||
expect(isInternalLink("https://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for mailto: links", () => {
|
||||
expect(isInternalLink("mailto:user@example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for relative paths", () => {
|
||||
expect(isInternalLink("/workspace?path=doc.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for @entry/ links", () => {
|
||||
expect(isInternalLink("@entry/leads/123")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for plain text", () => {
|
||||
expect(isInternalLink("some-page")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isEntryLink ──────────────────────────────────────────────────
|
||||
|
||||
describe("isEntryLink", () => {
|
||||
it("returns true for new format entry link", () => {
|
||||
expect(isEntryLink("/workspace?entry=leads:abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for legacy @entry/ format", () => {
|
||||
expect(isEntryLink("@entry/leads/abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for file workspace link", () => {
|
||||
expect(isEntryLink("/workspace?path=doc.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for external URL", () => {
|
||||
expect(isEntryLink("https://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for plain /workspace", () => {
|
||||
expect(isEntryLink("/workspace")).toBe(false);
|
||||
});
|
||||
});
|
||||
1055
apps/web/lib/workspace.test.ts
Normal file
1055
apps/web/lib/workspace.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -8,7 +8,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ["lib/**/*.test.ts"],
|
||||
include: ["lib/**/*.test.ts", "app/**/*.test.ts"],
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ironclaw",
|
||||
"version": "2026.2.15-1.4",
|
||||
"version": "2026.2.15-1.6",
|
||||
"description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user