From 3103719174391f31a24c42ed3df3c7144379e702 Mon Sep 17 00:00:00 2001 From: Bob Bai Date: Thu, 28 May 2026 11:21:32 -0700 Subject: [PATCH] test(agent-service): raise unit-test coverage of core modules Adds unit tests for the agent service's previously under-tested modules: API clients (workflow/compile/backend/auth, via mocked fetch), the system prompt builder, context assembly, workflow utilities, the workflow CRUD and execution tools, and the in-memory surface of TexeraAgent. Introduces a shared operator-metadata fixture so tests need no live backend. Line coverage rises from ~52% to ~89% and function coverage from ~54% to ~90% (bun test --coverage). Closes #5266 --- agent-service/src/agent/prompts.test.ts | 64 +++++ agent-service/src/agent/texera-agent.test.ts | 149 +++++++++++ .../agent/tools/workflow-crud-tools.test.ts | 241 ++++++++++++++++++ .../tools/workflow-execution-tools.test.ts | 179 +++++++++++++ .../src/agent/util/context-utils.test.ts | 170 ++++++++++++ .../src/agent/util/metadata-fixture.ts | 93 +++++++ .../src/agent/util/workflow-utils.test.ts | 95 +++++++ agent-service/src/api/auth-api.test.ts | 78 ++++++ agent-service/src/api/backend-api.test.ts | 63 +++++ agent-service/src/api/compile-api.test.ts | 76 ++++++ agent-service/src/api/workflow-api.test.ts | 117 +++++++++ 11 files changed, 1325 insertions(+) create mode 100644 agent-service/src/agent/prompts.test.ts create mode 100644 agent-service/src/agent/texera-agent.test.ts create mode 100644 agent-service/src/agent/tools/workflow-crud-tools.test.ts create mode 100644 agent-service/src/agent/tools/workflow-execution-tools.test.ts create mode 100644 agent-service/src/agent/util/context-utils.test.ts create mode 100644 agent-service/src/agent/util/metadata-fixture.ts create mode 100644 agent-service/src/agent/util/workflow-utils.test.ts create mode 100644 agent-service/src/api/auth-api.test.ts create mode 100644 agent-service/src/api/backend-api.test.ts create mode 100644 agent-service/src/api/compile-api.test.ts create mode 100644 agent-service/src/api/workflow-api.test.ts diff --git a/agent-service/src/agent/prompts.test.ts b/agent-service/src/agent/prompts.test.ts new file mode 100644 index 00000000000..383a2cd5395 --- /dev/null +++ b/agent-service/src/agent/prompts.test.ts @@ -0,0 +1,64 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { buildSystemPrompt } from "./prompts"; +import { makeMetadataFixture } from "./util/metadata-fixture"; +import { WorkflowSystemMetadata } from "./util/workflow-system-metadata"; + +describe("buildSystemPrompt", () => { + test("renders the base template and every operator when no allow-list is given", () => { + const prompt = buildSystemPrompt(makeMetadataFixture()); + expect(prompt).toContain("You are a data science Copilot"); + expect(prompt).toContain("## CSVFileScan"); + expect(prompt).toContain("Description: Load CSV data"); + expect(prompt).toContain("## Filter"); + expect(prompt).toContain("## PythonUDFV2"); + }); + + test("includes both the Python and R UDF guides when all operators are allowed", () => { + const prompt = buildSystemPrompt(makeMetadataFixture()); + expect(prompt).toContain("## Python UDF Guide"); + expect(prompt).toContain("## R UDF Guide"); + }); + + test("restricts the operator section to the allow-list", () => { + const prompt = buildSystemPrompt(makeMetadataFixture(), ["Filter"]); + expect(prompt).toContain("## Filter"); + expect(prompt).not.toContain("## CSVFileScan"); + expect(prompt).not.toContain("## PythonUDFV2"); + }); + + test("omits both UDF guides when the allow-list has no UDF operator", () => { + const prompt = buildSystemPrompt(makeMetadataFixture(), ["Filter"]); + expect(prompt).not.toContain("## Python UDF Guide"); + expect(prompt).not.toContain("## R UDF Guide"); + }); + + test("includes only the Python guide when PythonUDFV2 is the sole allowed operator", () => { + const prompt = buildSystemPrompt(makeMetadataFixture(), ["PythonUDFV2"]); + expect(prompt).toContain("## Python UDF Guide"); + expect(prompt).not.toContain("## R UDF Guide"); + }); + + test("falls back to a placeholder when no operator metadata is available", () => { + const prompt = buildSystemPrompt(new WorkflowSystemMetadata()); + expect(prompt).toContain("No operators available."); + }); +}); diff --git a/agent-service/src/agent/texera-agent.test.ts b/agent-service/src/agent/texera-agent.test.ts new file mode 100644 index 00000000000..19150fc17be --- /dev/null +++ b/agent-service/src/agent/texera-agent.test.ts @@ -0,0 +1,149 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { TexeraAgent } from "./texera-agent"; +import type { LanguageModel } from "ai"; +import { AgentState, INITIAL_STEP_ID } from "../types/agent"; + +// These tests exercise the agent's in-memory tree/settings/tool surface, which +// do not invoke the LLM, so a stub model is sufficient. The ReAct generation +// loop (sendMessage) talks to a real provider and is covered separately. +function newAgent(overrides: { agentName?: string } = {}): TexeraAgent { + return new TexeraAgent({ + model: {} as LanguageModel, + modelType: "test-model", + agentId: "a1", + agentName: overrides.agentName ?? "Bob", + }); +} + +describe("TexeraAgent - construction", () => { + test("starts AVAILABLE with the initial step as head and no visible steps", () => { + const agent = newAgent(); + expect(agent.getState()).toBe(AgentState.AVAILABLE); + expect(agent.getHead()).toBe(INITIAL_STEP_ID); + expect(agent.getAllSteps()).toEqual([]); + expect(agent.getReActSteps()).toEqual([]); + expect(agent.getAncestorPath()).toEqual([INITIAL_STEP_ID]); + }); + + test("exposes identity fields from the config", () => { + const agent = newAgent({ agentName: "Ada" }); + expect(agent.agentId).toBe("a1"); + expect(agent.agentName).toBe("Ada"); + expect(agent.modelType).toBe("test-model"); + expect(agent.createdAt).toBeInstanceOf(Date); + }); +}); + +describe("TexeraAgent - settings", () => { + test("returns the documented defaults", () => { + const settings = newAgent().getSettings(); + expect(settings.maxSteps).toBe(100); + expect(settings.maxOperatorResultCharLimit).toBe(2000); + }); + + test("updateSettings applies numeric overrides", () => { + const agent = newAgent(); + agent.updateSettings({ maxSteps: 5, maxOperatorResultCharLimit: 123, toolTimeoutMs: 1000 }); + const settings = agent.getSettings(); + expect(settings.maxSteps).toBe(5); + expect(settings.maxOperatorResultCharLimit).toBe(123); + expect(settings.toolTimeoutMs).toBe(1000); + }); + + test("changing allowedOperatorTypes rebuilds the system prompt", () => { + const agent = newAgent(); + agent.updateSettings({ allowedOperatorTypes: ["Filter"] }); + expect(agent.getSettings().allowedOperatorTypes).toEqual(["Filter"]); + // The rebuilt prompt always contains the base template header. + expect(agent.getSystemInfo().systemPrompt).toContain("You are a data science Copilot"); + }); +}); + +describe("TexeraAgent - tool surface", () => { + test("without a delegate, exposes only the CRUD tools", () => { + const names = newAgent() + .getSystemInfo() + .tools.map(t => t.name) + .sort(); + expect(names).toEqual(["addOperator", "deleteOperator", "modifyOperator"]); + }); + + test("setting a delegate config adds the execute-operator tool", () => { + const agent = newAgent(); + expect(agent.getDelegateConfig()).toBeUndefined(); + + agent.setDelegateConfig({ userToken: "tok", workflowId: 1, workflowName: "wf" }); + expect(agent.getDelegateConfig()?.workflowId).toBe(1); + + const names = agent + .getSystemInfo() + .tools.map(t => t.name) + .sort(); + expect(names).toContain("executeOperator"); + expect(names).toHaveLength(4); + + agent.destroy(); // tears down the persistence subscription + }); +}); + +describe("TexeraAgent - history and checkout", () => { + test("checkout rejects an unknown step but accepts the initial step", () => { + const agent = newAgent(); + expect(agent.checkout("does-not-exist")).toBe(false); + expect(agent.checkout(INITIAL_STEP_ID)).toBe(true); + expect(agent.getHead()).toBe(INITIAL_STEP_ID); + }); + + test("getReActStepsByOperatorIds returns empty when there is no history", () => { + const agent = newAgent(); + expect(agent.getReActStepsByOperatorIds([])).toEqual([]); + expect(agent.getReActStepsByOperatorIds(["op1"])).toEqual([]); + }); + + test("clearHistory resets head to the initial step", () => { + const agent = newAgent(); + agent.clearHistory(); + expect(agent.getHead()).toBe(INITIAL_STEP_ID); + expect(agent.getReActSteps()).toEqual([]); + }); +}); + +describe("TexeraAgent - lifecycle", () => { + test("stop transitions the state to STOPPING", () => { + const agent = newAgent(); + agent.stop(); + expect(agent.getState()).toBe(AgentState.STOPPING); + }); + + test("destroy clears history and is safe to call", () => { + const agent = newAgent(); + expect(() => agent.destroy()).not.toThrow(); + expect(agent.getReActSteps()).toEqual([]); + }); + + test("exposes its workflow and result state objects", () => { + const agent = newAgent(); + expect(agent.getWorkflowState()).toBeDefined(); + expect(agent.getWorkflowResultState()).toBeDefined(); + expect(agent.getMetadataStore()).toBeDefined(); + }); +}); diff --git a/agent-service/src/agent/tools/workflow-crud-tools.test.ts b/agent-service/src/agent/tools/workflow-crud-tools.test.ts new file mode 100644 index 00000000000..80ef5b9e6db --- /dev/null +++ b/agent-service/src/agent/tools/workflow-crud-tools.test.ts @@ -0,0 +1,241 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { + createAddOperatorTool, + createModifyOperatorTool, + createDeleteOperatorTool, + type ToolContext, +} from "./workflow-crud-tools"; +import { WorkflowState } from "../workflow-state"; +import { makeMetadataFixture, FIXTURE_METADATA } from "../util/metadata-fixture"; +import type { WorkflowSystemMetadata } from "../util/workflow-system-metadata"; + +// The AI SDK tool().execute takes (args, options); the CRUD tools ignore the +// options argument, so a minimal stub is sufficient. +const EXEC_OPTS = { toolCallId: "test-call", messages: [] } as any; + +function buildOperatorSchemas(store: WorkflowSystemMetadata): Map { + const map = new Map(); + for (const op of FIXTURE_METADATA.operators) { + map.set(op.operatorType, { jsonSchema: store.getSchema(op.operatorType) }); + } + return map; +} + +function setup() { + const state = new WorkflowState(); + const store = makeMetadataFixture(); + const context: ToolContext = { metadataStore: store }; + const addTool = createAddOperatorTool(state, buildOperatorSchemas(store), context); + const modifyTool = createModifyOperatorTool(state, context); + const deleteTool = createDeleteOperatorTool(state, context); + return { state, addTool, modifyTool, deleteTool }; +} + +async function run(tool: any, args: unknown): Promise { + return (await tool.execute(args, EXEC_OPTS)) as string; +} + +describe("addOperator tool", () => { + test("adds a valid source operator", async () => { + const { state, addTool } = setup(); + const out = await run(addTool, { + operatorId: "op1", + operatorType: "CSVFileScan", + properties: { fileName: "data.csv" }, + summary: "Load CSV", + }); + expect(out).toContain("Added operator op1"); + expect(state.getOperator("op1")).toBeDefined(); + expect(state.getOperator("op1")?.operatorProperties.fileName).toBe("data.csv"); + }); + + test("connects input links via inputOperatorIds", async () => { + const { state, addTool } = setup(); + await run(addTool, { + operatorId: "op1", + operatorType: "CSVFileScan", + properties: { fileName: "d.csv" }, + summary: "src", + }); + const out = await run(addTool, { + operatorId: "op2", + operatorType: "Filter", + properties: {}, + inputOperatorIds: { "0": ["op1"] }, + summary: "filter", + }); + expect(out).toContain("created links"); + expect(state.getAllLinks()).toHaveLength(1); + expect(state.getAllLinks()[0].source.operatorID).toBe("op1"); + expect(state.getAllLinks()[0].target.operatorID).toBe("op2"); + }); + + test("rejects an unknown operator type", async () => { + const { addTool } = setup(); + const out = await run(addTool, { operatorId: "op1", operatorType: "Nope", properties: {}, summary: "s" }); + expect(out).toContain("[ERROR]"); + expect(out).toContain("Unknown operator type"); + }); + + test("rejects properties that violate the operator schema", async () => { + const { addTool } = setup(); + // CSVFileScan requires fileName. + const out = await run(addTool, { operatorId: "op1", operatorType: "CSVFileScan", properties: {}, summary: "s" }); + expect(out).toContain("[ERROR]"); + expect(out).toContain("Invalid properties"); + }); + + test("rejects an operatorId that is not in op form", async () => { + const { addTool } = setup(); + const out = await run(addTool, { + operatorId: "weird-id", + operatorType: "CSVFileScan", + properties: { fileName: "d.csv" }, + summary: "s", + }); + expect(out).toContain("[ERROR]"); + expect(out).toContain("Invalid operatorId"); + }); + + test("rejects a duplicate operatorId", async () => { + const { addTool } = setup(); + const args = { operatorId: "op1", operatorType: "CSVFileScan", properties: { fileName: "d.csv" }, summary: "s" }; + await run(addTool, args); + const out = await run(addTool, args); + expect(out).toContain("already exists"); + }); + + test("rejects a link to a non-existent source operator", async () => { + const { addTool } = setup(); + const out = await run(addTool, { + operatorId: "op1", + operatorType: "Filter", + properties: {}, + inputOperatorIds: { "0": ["ghost"] }, + summary: "s", + }); + expect(out).toContain("[ERROR]"); + expect(out).toContain('Source operator "ghost" not found'); + }); + + test("rejects an out-of-range input port index", async () => { + const { state, addTool } = setup(); + await run(addTool, { + operatorId: "op1", + operatorType: "CSVFileScan", + properties: { fileName: "d.csv" }, + summary: "s", + }); + const out = await run(addTool, { + operatorId: "op2", + operatorType: "Filter", + properties: {}, + inputOperatorIds: { "5": ["op1"] }, + summary: "s", + }); + expect(out).toContain("[ERROR]"); + expect(out).toContain("out of range"); + expect(state.getOperator("op2")).toBeDefined(); // operator was added before link validation failed + }); +}); + +describe("modifyOperator tool", () => { + test("merges new properties into an existing operator", async () => { + const { state, addTool, modifyTool } = setup(); + await run(addTool, { + operatorId: "op1", + operatorType: "CSVFileScan", + properties: { fileName: "a.csv" }, + summary: "s", + }); + const out = await run(modifyTool, { operatorId: "op1", properties: { fileName: "b.csv" }, summary: "rename" }); + expect(out).toContain("Operator op1 modified"); + expect(state.getOperator("op1")?.operatorProperties.fileName).toBe("b.csv"); + }); + + test("returns an error for a missing operator", async () => { + const { modifyTool } = setup(); + const out = await run(modifyTool, { operatorId: "ghost", properties: {}, summary: "s" }); + expect(out).toContain("[ERROR]"); + expect(out).toContain("not found"); + }); + + test("replaces incoming links when inputOperatorIds is provided", async () => { + const { state, addTool, modifyTool } = setup(); + await run(addTool, { + operatorId: "op1", + operatorType: "CSVFileScan", + properties: { fileName: "a.csv" }, + summary: "s", + }); + await run(addTool, { + operatorId: "op2", + operatorType: "CSVFileScan", + properties: { fileName: "b.csv" }, + summary: "s", + }); + await run(addTool, { + operatorId: "op3", + operatorType: "Filter", + properties: {}, + inputOperatorIds: { "0": ["op1"] }, + summary: "s", + }); + expect(state.getAllLinks()).toHaveLength(1); + + const out = await run(modifyTool, { operatorId: "op3", inputOperatorIds: { "0": ["op2"] }, summary: "relink" }); + expect(out).toContain("deleted links"); + expect(out).toContain("created links"); + expect(state.getAllLinks()).toHaveLength(1); + expect(state.getAllLinks()[0].source.operatorID).toBe("op2"); + }); +}); + +describe("deleteOperator tool", () => { + test("deletes an existing operator and its links", async () => { + const { state, addTool, deleteTool } = setup(); + await run(addTool, { + operatorId: "op1", + operatorType: "CSVFileScan", + properties: { fileName: "a.csv" }, + summary: "s", + }); + await run(addTool, { + operatorId: "op2", + operatorType: "Filter", + properties: {}, + inputOperatorIds: { "0": ["op1"] }, + summary: "s", + }); + const out = await run(deleteTool, { operatorId: "op1" }); + expect(out).toContain("Deleted operator: op1"); + expect(state.getOperator("op1")).toBeUndefined(); + expect(state.getAllLinks()).toHaveLength(0); // connected link removed + }); + + test("returns an error when deleting a missing operator", async () => { + const { deleteTool } = setup(); + const out = await run(deleteTool, { operatorId: "ghost" }); + expect(out).toContain("[ERROR]"); + expect(out).toContain("not found"); + }); +}); diff --git a/agent-service/src/agent/tools/workflow-execution-tools.test.ts b/agent-service/src/agent/tools/workflow-execution-tools.test.ts new file mode 100644 index 00000000000..698770ada6e --- /dev/null +++ b/agent-service/src/agent/tools/workflow-execution-tools.test.ts @@ -0,0 +1,179 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { executeOperatorAndFormat, type ExecutionConfig } from "./workflow-execution-tools"; +import { WorkflowState } from "../workflow-state"; +import { WorkflowUtilService } from "../util/workflow-utils"; +import { WorkflowSystemMetadata } from "../util/workflow-system-metadata"; +import { FIXTURE_METADATA } from "../util/metadata-fixture"; +import type { OperatorInfo, SyncExecutionResult } from "../../types/execution"; + +const realFetch = globalThis.fetch; +let lastUrl = ""; +let lastInit: RequestInit | undefined; + +function stubFetch(result: SyncExecutionResult, status = 200): void { + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + lastUrl = String(url); + lastInit = init; + return new Response(JSON.stringify(result), { status }); + }) as unknown as typeof fetch; +} + +// validateWorkflow / schema validation read from the process-wide singleton. +beforeAll(() => { + WorkflowSystemMetadata.getInstance().loadFromMetadata(FIXTURE_METADATA); +}); + +afterEach(() => { + globalThis.fetch = realFetch; + lastUrl = ""; + lastInit = undefined; +}); + +function csvScanState(): { state: WorkflowState; operatorId: string } { + const state = new WorkflowState(); + const util = new WorkflowUtilService(WorkflowSystemMetadata.getInstance(), state); + let op = util.getNewOperatorPredicate("CSVFileScan"); + op = { ...op, operatorProperties: { ...op.operatorProperties, fileName: "data.csv" } }; + state.addOperator(op); + return { state, operatorId: op.operatorID }; +} + +const baseConfig: ExecutionConfig = { userToken: "tok-abc", workflowId: 7, maxOperatorResultCharLimit: 2000 }; + +describe("executeOperatorAndFormat - guards", () => { + test("errors when the workflow has no operators", async () => { + const out = await executeOperatorAndFormat(new WorkflowState(), baseConfig, "missing"); + expect(out).toContain("[ERROR]"); + expect(out).toContain("workflow has no operators"); + }); + + test("blocks execution on the target operator's validation errors", async () => { + // A Filter with no inbound links fails the input-connection check. + const state = new WorkflowState(); + const util = new WorkflowUtilService(WorkflowSystemMetadata.getInstance(), state); + const filter = util.getNewOperatorPredicate("Filter"); + state.addOperator(filter); + + const out = await executeOperatorAndFormat(state, baseConfig, filter.operatorID); + expect(out).toContain("[ERROR]"); + expect(out).toContain(`Operator ${filter.operatorID}`); + expect(out).toContain("inputs"); + }); +}); + +describe("executeOperatorAndFormat - successful run", () => { + test("posts to the per-cuid run endpoint with a bearer token", async () => { + const { state, operatorId } = csvScanState(); + const op: OperatorInfo = { state: "Completed", inputTuples: 0, outputTuples: 2, resultMode: "table", result: [] }; + stubFetch({ success: true, state: "Completed", operators: { [operatorId]: op } }); + + await executeOperatorAndFormat(state, baseConfig, operatorId); + + expect(lastUrl).toMatch(/\/api\/execution\/7\/0\/run$/); + expect(lastInit?.method).toBe("POST"); + expect((lastInit?.headers as Record).Authorization).toBe("Bearer tok-abc"); + const body = JSON.parse(String(lastInit?.body)); + expect(body.logicalPlan.operators).toHaveLength(1); + expect(body.targetOperatorIds).toContain(operatorId); + }); + + test("formats the result table and reports the output shape", async () => { + const { state, operatorId } = csvScanState(); + const op: OperatorInfo = { + state: "Completed", + inputTuples: 0, + outputTuples: 2, + resultMode: "table", + totalRowCount: 2, + result: [ + { a: 1, b: "x" }, + { a: 2, b: "y" }, + ], + }; + stubFetch({ success: true, state: "Completed", operators: { [operatorId]: op } }); + + const received: string[] = []; + const out = await executeOperatorAndFormat(state, baseConfig, operatorId, { + onResult: opId => received.push(opId), + }); + + expect(out).toContain(`Executed operator ${operatorId}`); + expect(out).toContain("Output table shape: (2, 2)"); + expect(out).toContain("a\tb"); + expect(out).toContain("1\tx"); + // onResult is invoked for each non-errored operator in the execution. + expect(received).toContain(operatorId); + }); + + test("returns a placeholder when the operator has no result rows", async () => { + const { state, operatorId } = csvScanState(); + const op: OperatorInfo = { state: "Completed", inputTuples: 0, outputTuples: 0, resultMode: "table" }; + stubFetch({ success: true, state: "Completed", operators: { [operatorId]: op } }); + + const out = await executeOperatorAndFormat(state, baseConfig, operatorId); + expect(out).toBe("(no result data)"); + }); +}); + +describe("executeOperatorAndFormat - failures", () => { + test("surfaces a per-operator execution error and notifies onResult", async () => { + const { state, operatorId } = csvScanState(); + const op: OperatorInfo = { + state: "Failed", + inputTuples: 0, + outputTuples: 0, + resultMode: "table", + error: "division by zero", + }; + stubFetch({ success: false, state: "Failed", operators: { [operatorId]: op } }); + + let notified: OperatorInfo | undefined; + const out = await executeOperatorAndFormat(state, baseConfig, operatorId, { + onResult: (_opId, info) => { + notified = info; + }, + }); + + expect(out).toContain("[ERROR]"); + expect(out).toContain("Execution failed"); + expect(out).toContain("division by zero"); + expect(notified?.state).toBe("Failed"); + }); + + test("reports a timeout kill as a general error", async () => { + const { state, operatorId } = csvScanState(); + stubFetch({ success: false, state: "Killed", operators: {} }); + + const out = await executeOperatorAndFormat(state, baseConfig, operatorId); + expect(out).toContain("[ERROR]"); + expect(out).toContain("killed (timeout)"); + }); + + test("maps a non-ok HTTP response to an Error execution result", async () => { + const { state, operatorId } = csvScanState(); + globalThis.fetch = (async () => new Response("upstream boom", { status: 502, statusText: "Bad Gateway" })) as any; + + const out = await executeOperatorAndFormat(state, baseConfig, operatorId); + expect(out).toContain("[ERROR]"); + expect(out).toContain("Execution failed"); + }); +}); diff --git a/agent-service/src/agent/util/context-utils.test.ts b/agent-service/src/agent/util/context-utils.test.ts new file mode 100644 index 00000000000..c3e9afbd724 --- /dev/null +++ b/agent-service/src/agent/util/context-utils.test.ts @@ -0,0 +1,170 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { assembleContext } from "./context-utils"; +import { WorkflowState } from "../workflow-state"; +import type { ReActStep } from "../../types/agent"; +import type { OperatorPredicate, OperatorLink } from "../../types/workflow"; + +function userStep(messageId: string, content: string): ReActStep { + return { + id: `${messageId}-u`, + messageId, + stepId: 0, + timestamp: 0, + role: "user", + content, + isBegin: true, + isEnd: true, + }; +} + +function agentStep(messageId: string, opts: Partial & { stepId: number }): ReActStep { + return { + id: `${messageId}-a${opts.stepId}`, + messageId, + stepId: opts.stepId, + timestamp: 0, + role: "agent", + content: opts.content ?? "", + isBegin: false, + isEnd: opts.isEnd ?? false, + toolCalls: opts.toolCalls, + toolResults: opts.toolResults, + }; +} + +function makeOperator(id: string, overrides: Partial = {}): OperatorPredicate { + return { + operatorID: id, + operatorType: "TestOp", + operatorVersion: "1.0", + operatorProperties: {}, + inputPorts: [{ portID: "input-0", displayName: "Input 0" }], + outputPorts: [{ portID: "output-0", displayName: "Output 0" }], + showAdvanced: false, + ...overrides, + }; +} + +function makeLink(linkId: string, sourceId: string, targetId: string): OperatorLink { + return { + linkID: linkId, + source: { operatorID: sourceId, portID: "output-0" }, + target: { operatorID: targetId, portID: "input-0" }, + }; +} + +function contentOf( + steps: ReActStep[], + state: WorkflowState, + results = new Map(), + redact = false +): string { + const messages = assembleContext(steps, state, results, redact); + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe("user"); + return messages[0].content as string; +} + +describe("assembleContext - tasks", () => { + test("empty input yields a single empty user message", () => { + const messages = assembleContext([], new WorkflowState(), new Map()); + expect(messages).toEqual([{ role: "user", content: "" }]); + }); + + test("a finished agent turn renders under Completed Tasks", () => { + const steps = [userStep("m1", "load the file"), agentStep("m1", { stepId: 1, content: "done", isEnd: true })]; + const content = contentOf(steps, new WorkflowState()); + expect(content).toContain("# Completed Tasks"); + expect(content).toContain("## Task (completed)"); + expect(content).toContain("### User request"); + expect(content).toContain("load the file"); + expect(content).toContain("### Turn 1"); + expect(content).not.toContain("# Ongoing Task"); + }); + + test("an unfinished agent turn renders under Ongoing Task with the continue prompt", () => { + const steps = [userStep("m1", "keep going"), agentStep("m1", { stepId: 1, content: "thinking", isEnd: false })]; + const content = contentOf(steps, new WorkflowState()); + expect(content).toContain("# Ongoing Task"); + expect(content).toContain("Above is user's request"); + expect(content).not.toContain("# Completed Tasks"); + }); + + test("tool calls are listed with succeeded/failed status", () => { + const steps = [ + userStep("m1", "do it"), + agentStep("m1", { + stepId: 1, + isEnd: true, + toolCalls: [ + { toolName: "addOperator", toolCallId: "t1", input: {} }, + { toolName: "modifyOperator", toolCallId: "t2", input: {} }, + ], + toolResults: [ + { toolCallId: "t1", output: "ok", isError: false }, + { toolCallId: "t2", output: "[ERROR] bad", isError: true }, + ], + }), + ]; + const content = contentOf(steps, new WorkflowState()); + expect(content).toContain("- addOperator (succeeded)"); + expect(content).toContain("- modifyOperator (failed)"); + }); +}); + +describe("assembleContext - dataflow", () => { + function twoOpState(): WorkflowState { + const state = new WorkflowState(); + state.addOperator(makeOperator("op1")); + state.addOperator(makeOperator("op2")); + state.addLink(makeLink("l1", "op1", "op2")); + return state; + } + + test("renders the operator list and links of a non-empty workflow", () => { + const content = contentOf([], twoOpState()); + expect(content).toContain("# Current Dataflow"); + expect(content).toContain("## Operators"); + expect(content).toContain("### Operator `op1` (TestOp, not-executed)"); + expect(content).toContain("## Links"); + expect(content).toContain("- op1 → op2"); + }); + + test("an operator with a result is marked executed and shows the result block", () => { + const results = new Map([["op1", "rows: 10"]]); + const content = contentOf([], twoOpState(), results); + expect(content).toContain("### Operator `op1` (TestOp, executed)"); + expect(content).toContain("Result:"); + expect(content).toContain("rows: 10"); + }); + + test("an operator whose result contains [ERROR] is marked failed", () => { + const results = new Map([["op1", "[ERROR] boom"]]); + const content = contentOf([], twoOpState(), results); + expect(content).toContain("### Operator `op1` (TestOp, failed)"); + }); + + test("no dataflow section when the workflow is empty", () => { + const content = contentOf([], new WorkflowState()); + expect(content).not.toContain("# Current Dataflow"); + }); +}); diff --git a/agent-service/src/agent/util/metadata-fixture.ts b/agent-service/src/agent/util/metadata-fixture.ts new file mode 100644 index 00000000000..92054fbf0e1 --- /dev/null +++ b/agent-service/src/agent/util/metadata-fixture.ts @@ -0,0 +1,93 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Shared operator-metadata fixture for unit tests. Seeds a WorkflowSystemMetadata +// instance with a small, deterministic set of operators (a source, a transform, +// and a Python UDF) so tests do not need a live backend. + +import { WorkflowSystemMetadata } from "./workflow-system-metadata"; +import type { OperatorMetadata, OperatorSchema } from "../../api/backend-api"; + +interface PortShape { + inputPorts: Record[]; + outputPorts: Record[]; +} + +function operator(operatorType: string, description: string, ports: PortShape, jsonSchema: unknown): OperatorSchema { + return { + operatorType, + jsonSchema, + operatorVersion: "1.0", + additionalMetadata: { + userFriendlyName: operatorType, + operatorGroupName: "Test", + operatorDescription: description, + inputPorts: ports.inputPorts, + outputPorts: ports.outputPorts, + }, + }; +} + +export const FIXTURE_METADATA: OperatorMetadata = { + operators: [ + operator( + "CSVFileScan", + "Load CSV data", + { inputPorts: [], outputPorts: [{}] }, + { + type: "object", + properties: { + fileName: { type: "string" }, + delimiter: { type: "string", default: "," }, + }, + required: ["fileName"], + additionalProperties: false, + } + ), + operator( + "Filter", + "Filter rows", + { inputPorts: [{}], outputPorts: [{}] }, + { + type: "object", + properties: { predicate: { type: "string" } }, + required: [], + additionalProperties: false, + } + ), + operator( + "PythonUDFV2", + "Run Python code", + { inputPorts: [{}], outputPorts: [{}] }, + { + type: "object", + properties: { code: { type: "string" } }, + required: [], + additionalProperties: false, + } + ), + ], + groups: [], +}; + +export function makeMetadataFixture(): WorkflowSystemMetadata { + const store = new WorkflowSystemMetadata(); + store.loadFromMetadata(FIXTURE_METADATA); + return store; +} diff --git a/agent-service/src/agent/util/workflow-utils.test.ts b/agent-service/src/agent/util/workflow-utils.test.ts new file mode 100644 index 00000000000..b26f87b68da --- /dev/null +++ b/agent-service/src/agent/util/workflow-utils.test.ts @@ -0,0 +1,95 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { extractOperatorInputPortSchemaMap, WorkflowUtilService } from "./workflow-utils"; +import { makeMetadataFixture } from "./metadata-fixture"; +import { WorkflowState } from "../workflow-state"; +import type { OperatorPredicate, OperatorLink, OperatorPortSchemaMap } from "../../types/workflow"; + +function targetOperator(): OperatorPredicate { + return { + operatorID: "tgt", + operatorType: "Filter", + operatorVersion: "1.0", + operatorProperties: {}, + inputPorts: [{ portID: "input-0", displayName: "Input 0" }], + outputPorts: [{ portID: "output-0", displayName: "Output 0" }], + showAdvanced: false, + }; +} + +function link(): OperatorLink { + return { + linkID: "l1", + source: { operatorID: "src", portID: "output-0" }, + target: { operatorID: "tgt", portID: "input-0" }, + }; +} + +describe("extractOperatorInputPortSchemaMap", () => { + test("resolves the upstream output schema onto the matching input port", () => { + const outputSchemas: Record = { + src: { "0_false": [{ attributeName: "a", attributeType: "string" }] }, + }; + + const result = extractOperatorInputPortSchemaMap("tgt", targetOperator(), outputSchemas, [link()]); + + expect(result).toBeDefined(); + expect(result!["0_false"]).toEqual([{ attributeName: "a", attributeType: "string" }]); + }); + + test("returns undefined when the operator has no inbound links", () => { + expect(extractOperatorInputPortSchemaMap("tgt", targetOperator(), {}, [])).toBeUndefined(); + }); + + test("returns undefined when the upstream operator has no known schema", () => { + // Link exists but there is no entry for "src" in outputSchemas. + expect(extractOperatorInputPortSchemaMap("tgt", targetOperator(), {}, [link()])).toBeUndefined(); + }); +}); + +describe("WorkflowUtilService.getNewOperatorPredicate", () => { + test("materializes a source operator with schema defaults and metadata ports", () => { + const service = new WorkflowUtilService(makeMetadataFixture(), new WorkflowState()); + + const op = service.getNewOperatorPredicate("CSVFileScan"); + + expect(op.operatorType).toBe("CSVFileScan"); + expect(op.operatorID).toBe("CSVFileScan-operator-1"); + expect(op.inputPorts).toHaveLength(0); // source: no input ports + expect(op.outputPorts).toHaveLength(1); + // Ajv useDefaults populates the default delimiter. + expect(op.operatorProperties.delimiter).toBe(","); + // No custom name -> falls back to the friendly name. + expect(op.customDisplayName).toBe("CSVFileScan"); + }); + + test("applies a custom display name when provided", () => { + const service = new WorkflowUtilService(makeMetadataFixture(), new WorkflowState()); + const op = service.getNewOperatorPredicate("Filter", "Drop nulls"); + expect(op.customDisplayName).toBe("Drop nulls"); + expect(op.inputPorts).toHaveLength(1); + }); + + test("throws for an operator type absent from metadata", () => { + const service = new WorkflowUtilService(makeMetadataFixture(), new WorkflowState()); + expect(() => service.getNewOperatorPredicate("DoesNotExist")).toThrow(/doesn't exist in operator metadata/); + }); +}); diff --git a/agent-service/src/api/auth-api.test.ts b/agent-service/src/api/auth-api.test.ts new file mode 100644 index 00000000000..7384e3bedba --- /dev/null +++ b/agent-service/src/api/auth-api.test.ts @@ -0,0 +1,78 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { createAuthHeaders, extractUserFromToken, validateToken } from "./auth-api"; + +// Builds a syntactically valid (unsigned) JWT for the given payload. The +// service only base64-decodes the payload — it does not verify the signature — +// so a dummy signature segment is sufficient for these tests. +function makeJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64"); + return `${header}.${body}.signature`; +} + +describe("extractUserFromToken", () => { + test("maps JWT claims to UserInfo", () => { + const token = makeJwt({ userId: 42, sub: "alice", email: "alice@example.com", role: "ADMIN" }); + const user = extractUserFromToken(token); + expect(user).toEqual({ uid: 42, name: "alice", email: "alice@example.com", role: "ADMIN" }); + }); + + test("defaults email to empty string and role to REGULAR when absent", () => { + const token = makeJwt({ userId: 7, sub: "bob" }); + const user = extractUserFromToken(token); + expect(user.email).toBe(""); + expect(user.role).toBe("REGULAR"); + }); + + test("throws on a token without three segments", () => { + expect(() => extractUserFromToken("not-a-jwt")).toThrow(/Failed to decode JWT/); + }); +}); + +describe("validateToken", () => { + test("accepts a token with no exp claim", () => { + expect(validateToken(makeJwt({ userId: 1, sub: "x" }))).toBe(true); + }); + + test("accepts a token whose exp is in the future", () => { + const future = Math.floor(Date.now() / 1000) + 3600; + expect(validateToken(makeJwt({ userId: 1, sub: "x", exp: future }))).toBe(true); + }); + + test("rejects a token whose exp is in the past", () => { + const past = Math.floor(Date.now() / 1000) - 3600; + expect(validateToken(makeJwt({ userId: 1, sub: "x", exp: past }))).toBe(false); + }); + + test("rejects a malformed token", () => { + expect(validateToken("obviously-not-a-jwt")).toBe(false); + }); +}); + +describe("createAuthHeaders", () => { + test("produces a Bearer Authorization header and JSON content type", () => { + expect(createAuthHeaders("abc.def.ghi")).toEqual({ + Authorization: "Bearer abc.def.ghi", + "Content-Type": "application/json", + }); + }); +}); diff --git a/agent-service/src/api/backend-api.test.ts b/agent-service/src/api/backend-api.test.ts new file mode 100644 index 00000000000..3234460f37a --- /dev/null +++ b/agent-service/src/api/backend-api.test.ts @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { fetchOperatorMetadata, getBackendConfig } from "./backend-api"; + +const realFetch = globalThis.fetch; +let lastUrl = ""; + +function stubFetch(handler: () => Response): void { + globalThis.fetch = (async (url: unknown) => { + lastUrl = String(url); + return handler(); + }) as unknown as typeof fetch; +} + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("getBackendConfig", () => { + test("exposes the configured endpoints and returns a defensive copy", () => { + const a = getBackendConfig(); + const b = getBackendConfig(); + expect(a).not.toBe(b); // a fresh object each call + expect(typeof a.apiEndpoint).toBe("string"); + expect(typeof a.compileEndpoint).toBe("string"); + expect(typeof a.executionEndpoint).toBe("string"); + }); +}); + +describe("fetchOperatorMetadata", () => { + test("requests the operator-metadata resource and returns the parsed body", async () => { + const metadata = { operators: [], groups: [] }; + stubFetch(() => new Response(JSON.stringify(metadata), { status: 200 })); + + const result = await fetchOperatorMetadata(); + + expect(result).toEqual(metadata); + expect(lastUrl).toMatch(/\/api\/resources\/operator-metadata$/); + }); + + test("throws with the status on a non-ok response", async () => { + stubFetch(() => new Response("err", { status: 503, statusText: "Service Unavailable" })); + await expect(fetchOperatorMetadata()).rejects.toThrow(/Failed to fetch operator metadata: 503/); + }); +}); diff --git a/agent-service/src/api/compile-api.test.ts b/agent-service/src/api/compile-api.test.ts new file mode 100644 index 00000000000..eb5ec3d34cc --- /dev/null +++ b/agent-service/src/api/compile-api.test.ts @@ -0,0 +1,76 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { compileWorkflowAsync } from "./compile-api"; +import type { LogicalPlan } from "../types/workflow"; + +const realFetch = globalThis.fetch; +let lastBody: unknown; +let lastUrl = ""; + +function stubFetch(handler: () => Response | Promise): void { + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + lastUrl = String(url); + lastBody = init?.body ? JSON.parse(String(init.body)) : undefined; + return handler(); + }) as unknown as typeof fetch; +} + +const plan: LogicalPlan = { + operators: [{ operatorID: "op1", operatorType: "CSVFileScan" }], + links: [], +}; + +beforeEach(() => { + lastBody = undefined; + lastUrl = ""; +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("compileWorkflowAsync", () => { + test("POSTs to /api/compile and returns the parsed response on success", async () => { + const payload = { operatorOutputSchemas: {}, operatorErrors: {} }; + stubFetch(() => new Response(JSON.stringify(payload), { status: 200 })); + + const result = await compileWorkflowAsync(plan); + + expect(result).toEqual(payload); + expect(lastUrl).toMatch(/\/api\/compile$/); + // request body forwards operators/links and pins the result-selection fields + expect((lastBody as any).operators).toHaveLength(1); + expect((lastBody as any).opsToReuseResult).toEqual([]); + expect((lastBody as any).opsToViewResult).toEqual([]); + }); + + test("returns null (not throw) on a non-ok response", async () => { + stubFetch(() => new Response("bad plan", { status: 400, statusText: "Bad Request" })); + expect(await compileWorkflowAsync(plan)).toBeNull(); + }); + + test("returns null when fetch itself rejects", async () => { + stubFetch(() => { + throw new Error("network down"); + }); + expect(await compileWorkflowAsync(plan)).toBeNull(); + }); +}); diff --git a/agent-service/src/api/workflow-api.test.ts b/agent-service/src/api/workflow-api.test.ts new file mode 100644 index 00000000000..7fa64b17bc0 --- /dev/null +++ b/agent-service/src/api/workflow-api.test.ts @@ -0,0 +1,117 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { persistWorkflow, retrieveWorkflow } from "./workflow-api"; +import type { WorkflowContent } from "../types/workflow"; + +interface RecordedCall { + url: string; + init?: RequestInit; +} + +const realFetch = globalThis.fetch; +let calls: RecordedCall[] = []; + +function stubFetch(handler: (url: string, init?: RequestInit) => Response): void { + calls = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return handler(String(url), init); + }) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +const emptyContent: WorkflowContent = { + operators: [], + operatorPositions: {}, + links: [], + commentBoxes: [], + settings: { dataTransferBatchSize: 400 }, +}; + +beforeEach(() => { + calls = []; +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("retrieveWorkflow", () => { + test("GETs the workflow by id with a Bearer token and parses string content", async () => { + stubFetch(() => jsonResponse({ wid: 5, name: "wf", content: JSON.stringify({ operators: [], links: [] }) })); + + const wf = await retrieveWorkflow("tok-123", 5); + + expect(wf.wid).toBe(5); + // content arrives as a JSON string and must be parsed into an object + expect(wf.content).toEqual({ operators: [], links: [] } as unknown as WorkflowContent); + expect(calls).toHaveLength(1); + expect(calls[0].url).toMatch(/\/api\/workflow\/5$/); + expect(calls[0].init?.method).toBe("GET"); + expect((calls[0].init?.headers as Record).Authorization).toBe("Bearer tok-123"); + }); + + test("leaves already-parsed object content untouched", async () => { + stubFetch(() => jsonResponse({ wid: 1, name: "wf", content: { operators: [], links: [] } })); + const wf = await retrieveWorkflow("t", 1); + expect(wf.content).toEqual({ operators: [], links: [] } as unknown as WorkflowContent); + }); + + test("throws with status text on a non-ok response", async () => { + stubFetch(() => new Response("nope", { status: 404, statusText: "Not Found" })); + await expect(retrieveWorkflow("t", 99)).rejects.toThrow(/Failed to retrieve workflow: 404/); + }); +}); + +describe("persistWorkflow", () => { + test("POSTs the workflow with stringified content and auth header", async () => { + stubFetch(() => jsonResponse({ wid: 8, name: "saved", content: { operators: [], links: [] } })); + + const result = await persistWorkflow("tok-xyz", 8, "saved", emptyContent, "desc"); + + expect(result.wid).toBe(8); + expect(calls[0].url).toMatch(/\/api\/workflow\/persist$/); + expect(calls[0].init?.method).toBe("POST"); + + const sentBody = JSON.parse(String(calls[0].init?.body)); + expect(sentBody.wid).toBe(8); + expect(sentBody.name).toBe("saved"); + expect(sentBody.description).toBe("desc"); + // content must be serialized to a string in the request body + expect(typeof sentBody.content).toBe("string"); + expect(JSON.parse(sentBody.content)).toEqual(emptyContent); + }); + + test("defaults description to empty string when omitted", async () => { + stubFetch(() => jsonResponse({ wid: 2, name: "n", content: { operators: [], links: [] } })); + await persistWorkflow("t", 2, "n", emptyContent); + const sentBody = JSON.parse(String(calls[0].init?.body)); + expect(sentBody.description).toBe(""); + }); + + test("throws on a non-ok response", async () => { + stubFetch(() => new Response("boom", { status: 500, statusText: "Server Error" })); + await expect(persistWorkflow("t", 1, "n", emptyContent)).rejects.toThrow(/Failed to persist workflow: 500/); + }); +});