- Convert sync filesystem and DuckDB operations to async across API routes, workspace lib, and active-runs to prevent event loop blocking during tree discovery, object lookups, and database queries - Add "tags" field type for free-form string arrays with parse-tags utility, TagsBadges/TagsInput UI components, filter operators, and CRM skill docs - Preserve rich text formatting (bold, italic, code, @mentions) in user chat messages by sending HTML alongside plain text through the transport layer - Detect empty-stream errors, improve agent error emission, and add file mutation queues for concurrent write safety in active-runs - Add pre-publish standalone node_modules verification in deploy script checking serverExternalPackages are present - Extract syncManagedSkills and discoverWorkspaceDirs for multi-workspace skill syncing, add ensureSeedAssets for runtime app dir - Bump version 2.1.1 → 2.1.4
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
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, duckdbQueryOnFileAsync } = await import("@/lib/workspace");
|
|
vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
|
|
vi.mocked(duckdbQueryOnFileAsync).mockResolvedValue([{ 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, duckdbQueryOnFileAsync } = await import("@/lib/workspace");
|
|
vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
|
|
vi.mocked(duckdbQueryOnFileAsync).mockResolvedValue([]);
|
|
|
|
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 { duckdbQueryAsync } = await import("@/lib/workspace");
|
|
vi.mocked(duckdbQueryAsync).mockResolvedValue([{ 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 { duckdbQueryAsync } = await import("@/lib/workspace");
|
|
vi.mocked(duckdbQueryAsync).mockResolvedValue([{ 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 { duckdbQueryAsync } = await import("@/lib/workspace");
|
|
vi.mocked(duckdbQueryAsync).mockResolvedValue([{ 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);
|
|
});
|
|
});
|
|
});
|