openclaw/apps/web/app/api/workspace/objects.test.ts
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

509 lines
20 KiB
TypeScript

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),
duckdbPathAsync: vi.fn(async () => null),
duckdbQueryOnFile: vi.fn(() => []),
duckdbQueryOnFileAsync: vi.fn(async () => []),
duckdbExecOnFile: vi.fn(() => true),
duckdbExecOnFileAsync: vi.fn(async () => true),
findDuckDBForObject: vi.fn(() => null),
findDuckDBForObjectAsync: vi.fn(async () => null),
getObjectViews: vi.fn(() => ({ views: [], activeView: null })),
parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])),
resolveDuckdbBin: vi.fn(() => null),
discoverDuckDBPaths: vi.fn(() => []),
discoverDuckDBPathsAsync: vi.fn(async () => []),
}));
describe("Workspace Objects API", () => {
beforeEach(() => {
vi.resetModules();
vi.mock("node:child_process", () => ({
execSync: vi.fn(() => ""),
}));
vi.mock("@/lib/workspace", () => ({
duckdbPath: vi.fn(() => null),
duckdbPathAsync: vi.fn(async () => null),
duckdbQueryOnFile: vi.fn(() => []),
duckdbQueryOnFileAsync: vi.fn(async () => []),
duckdbExecOnFile: vi.fn(() => true),
duckdbExecOnFileAsync: vi.fn(async () => true),
findDuckDBForObject: vi.fn(() => null),
findDuckDBForObjectAsync: vi.fn(async () => null),
getObjectViews: vi.fn(() => ({ views: [], activeView: null })),
parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])),
resolveDuckdbBin: vi.fn(() => null),
discoverDuckDBPaths: vi.fn(() => []),
discoverDuckDBPathsAsync: vi.fn(async () => []),
}));
});
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 { findDuckDBForObjectAsync, resolveDuckdbBin, duckdbPathAsync: mockDuckdbPath } = await import("@/lib/workspace");
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
vi.mocked(findDuckDBForObjectAsync).mockResolvedValue(null);
vi.mocked(mockDuckdbPath).mockResolvedValue(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 { findDuckDBForObjectAsync, duckdbQueryOnFileAsync, resolveDuckdbBin, discoverDuckDBPathsAsync } = await import("@/lib/workspace");
vi.mocked(findDuckDBForObjectAsync).mockResolvedValue("/ws/workspace.duckdb");
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
vi.mocked(discoverDuckDBPathsAsync).mockResolvedValue(["/ws/workspace.duckdb"]);
// Mock different queries with a call counter
let queryCall = 0;
vi.mocked(duckdbQueryOnFileAsync).mockImplementation(async () => {
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("loads same-db schema queries sequentially (prevents oscillating empty fields during live refresh)", async () => {
const {
findDuckDBForObjectAsync,
duckdbQueryOnFileAsync,
resolveDuckdbBin,
discoverDuckDBPathsAsync,
} = await import("@/lib/workspace");
vi.mocked(findDuckDBForObjectAsync).mockResolvedValue("/ws/workspace.duckdb");
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
vi.mocked(discoverDuckDBPathsAsync).mockResolvedValue(["/ws/workspace.duckdb"]);
let inFlight = 0;
vi.mocked(duckdbQueryOnFileAsync).mockImplementation(async (_dbFile, sql) => {
inFlight += 1;
const concurrent = inFlight > 1;
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
if (sql.includes("SELECT * FROM objects WHERE name")) {
return [{ id: "obj1", name: "company", description: "Company object" }] as never;
}
if (sql.includes("SELECT * FROM fields")) {
return concurrent
? ([] as never)
: ([{ id: "f1", name: "Company Name", type: "text", sort_order: 0 }] as never);
}
if (sql.includes("SELECT * FROM statuses")) {
return concurrent
? ([] as never)
: ([{ id: "status1", name: "Active", sort_order: 0 }] as never);
}
return [] as never;
});
const { GET } = await import("./objects/[name]/route.js");
const res = await GET(
new Request("http://localhost/api/workspace/objects/company"),
{ params: Promise.resolve({ name: "company" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.fields).toHaveLength(1);
expect(json.statuses).toHaveLength(1);
});
it("returns saved views and active view from object yaml metadata", async () => {
const {
findDuckDBForObjectAsync,
duckdbQueryOnFileAsync,
resolveDuckdbBin,
discoverDuckDBPathsAsync,
getObjectViews,
} = await import("@/lib/workspace");
vi.mocked(findDuckDBForObjectAsync).mockResolvedValue("/ws/workspace.duckdb");
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
vi.mocked(discoverDuckDBPathsAsync).mockResolvedValue(["/ws/workspace.duckdb"]);
vi.mocked(getObjectViews).mockReturnValue({
views: [
{
name: "Important",
filters: {
id: "root",
conjunction: "and",
rules: [
{ id: "rule-1", field: "Status", operator: "is", value: "Important" },
],
},
columns: ["Name", "Status"],
},
],
activeView: "Important",
viewSettings: undefined,
});
let queryCall = 0;
vi.mocked(duckdbQueryOnFileAsync).mockImplementation(async () => {
queryCall += 1;
if (queryCall === 1) {
return [{ id: "obj1", name: "leads", description: "Leads object", icon: "star" }];
}
if (queryCall === 2) {
return [{ id: "f1", name: "name", type: "text", sort_order: 0 }];
}
if (queryCall === 3) {
return [];
}
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.savedViews).toHaveLength(1);
expect(json.savedViews[0].name).toBe("Important");
expect(json.activeView).toBe("Important");
});
it("accepts underscored names", async () => {
const { findDuckDBForObjectAsync } = await import("@/lib/workspace");
vi.mocked(findDuckDBForObjectAsync).mockResolvedValue(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);
});
});
});