453 lines
18 KiB
TypeScript
453 lines
18 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),
|
|
duckdbQueryOnFile: vi.fn(() => []),
|
|
duckdbExecOnFile: vi.fn(() => true),
|
|
findDuckDBForObject: vi.fn(() => null),
|
|
getObjectViews: vi.fn(() => ({ views: [], activeView: 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),
|
|
getObjectViews: vi.fn(() => ({ views: [], activeView: 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("returns saved views and active view from object yaml metadata", async () => {
|
|
const {
|
|
findDuckDBForObject,
|
|
duckdbQueryOnFile,
|
|
resolveDuckdbBin,
|
|
discoverDuckDBPaths,
|
|
getObjectViews,
|
|
} = 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"]);
|
|
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(duckdbQueryOnFile).mockImplementation(() => {
|
|
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 { 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);
|
|
});
|
|
});
|
|
});
|