kumarabhirup 039cbe6a43
feat: async I/O, tags field type, rich chat messages, deploy verification
- 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
2026-03-08 19:53:18 -07:00

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);
});
});
});