454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
vi.mock("../src/logger.js", () => ({
|
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
import { registerActionsFunction } from "../src/functions/actions.js";
|
|
import type { Action, ActionEdge } from "../src/types.js";
|
|
import { mockKV, mockSdk } from "./helpers/mocks.js";
|
|
|
|
describe("Actions Functions", () => {
|
|
let sdk: ReturnType<typeof mockSdk>;
|
|
let kv: ReturnType<typeof mockKV>;
|
|
|
|
beforeEach(() => {
|
|
sdk = mockSdk();
|
|
kv = mockKV();
|
|
registerActionsFunction(sdk as never, kv as never);
|
|
});
|
|
|
|
describe("mem::action-create", () => {
|
|
it("creates an action with valid data", async () => {
|
|
const result = (await sdk.trigger("mem::action-create", {
|
|
title: "Fix login bug",
|
|
description: "Users cannot log in with SSO",
|
|
priority: 7,
|
|
createdBy: "agent-1",
|
|
project: "webapp",
|
|
tags: ["bug", "auth"],
|
|
})) as { success: boolean; action: Action; edges: ActionEdge[] };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.action.id).toMatch(/^act_/);
|
|
expect(result.action.title).toBe("Fix login bug");
|
|
expect(result.action.description).toBe("Users cannot log in with SSO");
|
|
expect(result.action.status).toBe("pending");
|
|
expect(result.action.priority).toBe(7);
|
|
expect(result.action.createdBy).toBe("agent-1");
|
|
expect(result.action.project).toBe("webapp");
|
|
expect(result.action.tags).toEqual(["bug", "auth"]);
|
|
expect(result.action.createdAt).toBeDefined();
|
|
expect(result.action.updatedAt).toBeDefined();
|
|
expect(result.edges).toEqual([]);
|
|
});
|
|
|
|
it("returns error when title is missing", async () => {
|
|
const result = (await sdk.trigger("mem::action-create", {
|
|
description: "No title provided",
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("title is required");
|
|
});
|
|
|
|
it("clamps priority 0 to default 5 (falsy fallback)", async () => {
|
|
const result = (await sdk.trigger("mem::action-create", {
|
|
title: "Zero priority task",
|
|
priority: 0,
|
|
})) as { success: boolean; action: Action };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.action.priority).toBe(5);
|
|
});
|
|
|
|
it("clamps negative priority to 1", async () => {
|
|
const result = (await sdk.trigger("mem::action-create", {
|
|
title: "Negative priority task",
|
|
priority: -3,
|
|
})) as { success: boolean; action: Action };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.action.priority).toBe(1);
|
|
});
|
|
|
|
it("clamps priority 15 to 10", async () => {
|
|
const result = (await sdk.trigger("mem::action-create", {
|
|
title: "High priority task",
|
|
priority: 15,
|
|
})) as { success: boolean; action: Action };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.action.priority).toBe(10);
|
|
});
|
|
|
|
it("validates parent action exists", async () => {
|
|
const result = (await sdk.trigger("mem::action-create", {
|
|
title: "Child task",
|
|
parentId: "nonexistent_parent",
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("parent action not found");
|
|
});
|
|
|
|
it("creates action with valid parent", async () => {
|
|
const parentResult = (await sdk.trigger("mem::action-create", {
|
|
title: "Parent task",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const childResult = (await sdk.trigger("mem::action-create", {
|
|
title: "Child task",
|
|
parentId: parentResult.action.id,
|
|
})) as { success: boolean; action: Action };
|
|
|
|
expect(childResult.success).toBe(true);
|
|
expect(childResult.action.parentId).toBe(parentResult.action.id);
|
|
});
|
|
|
|
it("creates inline edges with valid types", async () => {
|
|
const targetResult = (await sdk.trigger("mem::action-create", {
|
|
title: "Target action",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const result = (await sdk.trigger("mem::action-create", {
|
|
title: "Source action",
|
|
edges: [
|
|
{ type: "requires", targetActionId: targetResult.action.id },
|
|
{ type: "unlocks", targetActionId: targetResult.action.id },
|
|
],
|
|
})) as { success: boolean; action: Action; edges: ActionEdge[] };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.edges.length).toBe(2);
|
|
expect(result.edges[0].id).toMatch(/^ae_/);
|
|
expect(result.edges[0].type).toBe("requires");
|
|
expect(result.edges[0].sourceActionId).toBe(result.action.id);
|
|
expect(result.edges[0].targetActionId).toBe(targetResult.action.id);
|
|
expect(result.edges[1].type).toBe("unlocks");
|
|
});
|
|
|
|
it("returns error for inline edge with invalid type", async () => {
|
|
const targetResult = (await sdk.trigger("mem::action-create", {
|
|
title: "Target action",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const result = (await sdk.trigger("mem::action-create", {
|
|
title: "Source action",
|
|
edges: [
|
|
{ type: "invalid_type", targetActionId: targetResult.action.id },
|
|
],
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("invalid edge type");
|
|
});
|
|
|
|
it("returns error for inline edge with nonexistent target", async () => {
|
|
const result = (await sdk.trigger("mem::action-create", {
|
|
title: "Source action",
|
|
edges: [{ type: "requires", targetActionId: "nonexistent_id" }],
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("target action not found");
|
|
});
|
|
});
|
|
|
|
describe("mem::action-update", () => {
|
|
it("updates an action with valid data", async () => {
|
|
const createResult = (await sdk.trigger("mem::action-create", {
|
|
title: "Original title",
|
|
priority: 5,
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const updateResult = (await sdk.trigger("mem::action-update", {
|
|
actionId: createResult.action.id,
|
|
title: "Updated title",
|
|
priority: 8,
|
|
status: "active",
|
|
assignedTo: "agent-2",
|
|
tags: ["updated"],
|
|
})) as { success: boolean; action: Action };
|
|
|
|
expect(updateResult.success).toBe(true);
|
|
expect(updateResult.action.title).toBe("Updated title");
|
|
expect(updateResult.action.priority).toBe(8);
|
|
expect(updateResult.action.status).toBe("active");
|
|
expect(updateResult.action.assignedTo).toBe("agent-2");
|
|
expect(updateResult.action.tags).toEqual(["updated"]);
|
|
});
|
|
|
|
it("returns error when actionId is missing", async () => {
|
|
const result = (await sdk.trigger("mem::action-update", {
|
|
title: "no id",
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("actionId is required");
|
|
});
|
|
|
|
it("returns error for nonexistent action", async () => {
|
|
const result = (await sdk.trigger("mem::action-update", {
|
|
actionId: "nonexistent_id",
|
|
status: "done",
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("action not found");
|
|
});
|
|
|
|
it("propagates completion when status set to done", async () => {
|
|
const actionB = (await sdk.trigger("mem::action-create", {
|
|
title: "Dependency B",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const actionA = (await sdk.trigger("mem::action-create", {
|
|
title: "Action A depends on B",
|
|
edges: [{ type: "requires", targetActionId: actionB.action.id }],
|
|
})) as { success: boolean; action: Action };
|
|
|
|
await sdk.trigger("mem::action-update", {
|
|
actionId: actionA.action.id,
|
|
status: "blocked",
|
|
});
|
|
|
|
await sdk.trigger("mem::action-update", {
|
|
actionId: actionB.action.id,
|
|
status: "done",
|
|
});
|
|
|
|
const getResult = (await sdk.trigger("mem::action-get", {
|
|
actionId: actionA.action.id,
|
|
})) as { success: boolean; action: Action };
|
|
|
|
expect(getResult.action.status).toBe("pending");
|
|
});
|
|
});
|
|
|
|
describe("mem::action-edge-create", () => {
|
|
it("creates an edge between two actions", async () => {
|
|
const source = (await sdk.trigger("mem::action-create", {
|
|
title: "Source",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const target = (await sdk.trigger("mem::action-create", {
|
|
title: "Target",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const result = (await sdk.trigger("mem::action-edge-create", {
|
|
sourceActionId: source.action.id,
|
|
targetActionId: target.action.id,
|
|
type: "requires",
|
|
})) as { success: boolean; edge: ActionEdge };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.edge.id).toMatch(/^ae_/);
|
|
expect(result.edge.type).toBe("requires");
|
|
expect(result.edge.sourceActionId).toBe(source.action.id);
|
|
expect(result.edge.targetActionId).toBe(target.action.id);
|
|
expect(result.edge.createdAt).toBeDefined();
|
|
});
|
|
|
|
it("returns error when required fields are missing", async () => {
|
|
const result = (await sdk.trigger("mem::action-edge-create", {
|
|
sourceActionId: "some_id",
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("required");
|
|
});
|
|
|
|
it("returns error for invalid edge type", async () => {
|
|
const source = (await sdk.trigger("mem::action-create", {
|
|
title: "Source",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const target = (await sdk.trigger("mem::action-create", {
|
|
title: "Target",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const result = (await sdk.trigger("mem::action-edge-create", {
|
|
sourceActionId: source.action.id,
|
|
targetActionId: target.action.id,
|
|
type: "invalid_type",
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("type must be one of");
|
|
});
|
|
|
|
it("returns error for nonexistent source action", async () => {
|
|
const target = (await sdk.trigger("mem::action-create", {
|
|
title: "Target",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const result = (await sdk.trigger("mem::action-edge-create", {
|
|
sourceActionId: "nonexistent",
|
|
targetActionId: target.action.id,
|
|
type: "requires",
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("source action not found");
|
|
});
|
|
|
|
it("returns error for nonexistent target action", async () => {
|
|
const source = (await sdk.trigger("mem::action-create", {
|
|
title: "Source",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const result = (await sdk.trigger("mem::action-edge-create", {
|
|
sourceActionId: source.action.id,
|
|
targetActionId: "nonexistent",
|
|
type: "requires",
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("target action not found");
|
|
});
|
|
});
|
|
|
|
describe("mem::action-list", () => {
|
|
beforeEach(async () => {
|
|
await sdk.trigger("mem::action-create", {
|
|
title: "Task A",
|
|
status: "pending",
|
|
project: "alpha",
|
|
tags: ["frontend"],
|
|
});
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
await sdk.trigger("mem::action-create", {
|
|
title: "Task B",
|
|
project: "alpha",
|
|
tags: ["backend"],
|
|
});
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
await sdk.trigger("mem::action-create", {
|
|
title: "Task C",
|
|
project: "beta",
|
|
tags: ["frontend", "backend"],
|
|
});
|
|
});
|
|
|
|
it("returns all actions", async () => {
|
|
const result = (await sdk.trigger("mem::action-list", {})) as {
|
|
success: boolean;
|
|
actions: Action[];
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.actions.length).toBe(3);
|
|
});
|
|
|
|
it("filters by status", async () => {
|
|
const all = (await sdk.trigger("mem::action-list", {})) as {
|
|
actions: Action[];
|
|
};
|
|
const firstAction = all.actions[0];
|
|
|
|
await sdk.trigger("mem::action-update", {
|
|
actionId: firstAction.id,
|
|
status: "done",
|
|
});
|
|
|
|
const result = (await sdk.trigger("mem::action-list", {
|
|
status: "done",
|
|
})) as { success: boolean; actions: Action[] };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.actions.length).toBe(1);
|
|
expect(result.actions[0].status).toBe("done");
|
|
});
|
|
|
|
it("filters by project", async () => {
|
|
const result = (await sdk.trigger("mem::action-list", {
|
|
project: "alpha",
|
|
})) as { success: boolean; actions: Action[] };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.actions.length).toBe(2);
|
|
expect(result.actions.every((a) => a.project === "alpha")).toBe(true);
|
|
});
|
|
|
|
it("filters by tags", async () => {
|
|
const result = (await sdk.trigger("mem::action-list", {
|
|
tags: ["backend"],
|
|
})) as { success: boolean; actions: Action[] };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.actions.length).toBe(2);
|
|
expect(
|
|
result.actions.every((a) => a.tags.includes("backend")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("respects limit", async () => {
|
|
const result = (await sdk.trigger("mem::action-list", {
|
|
limit: 2,
|
|
})) as { success: boolean; actions: Action[] };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.actions.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("mem::action-get", () => {
|
|
it("returns action with edges and children", async () => {
|
|
const parent = (await sdk.trigger("mem::action-create", {
|
|
title: "Parent",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const child = (await sdk.trigger("mem::action-create", {
|
|
title: "Child",
|
|
parentId: parent.action.id,
|
|
})) as { success: boolean; action: Action };
|
|
|
|
const other = (await sdk.trigger("mem::action-create", {
|
|
title: "Other",
|
|
})) as { success: boolean; action: Action };
|
|
|
|
await sdk.trigger("mem::action-edge-create", {
|
|
sourceActionId: parent.action.id,
|
|
targetActionId: other.action.id,
|
|
type: "unlocks",
|
|
});
|
|
|
|
const result = (await sdk.trigger("mem::action-get", {
|
|
actionId: parent.action.id,
|
|
})) as {
|
|
success: boolean;
|
|
action: Action;
|
|
edges: ActionEdge[];
|
|
children: Action[];
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.action.id).toBe(parent.action.id);
|
|
expect(result.edges.length).toBe(1);
|
|
expect(result.edges[0].type).toBe("unlocks");
|
|
expect(result.children.length).toBe(1);
|
|
expect(result.children[0].id).toBe(child.action.id);
|
|
});
|
|
|
|
it("returns error for missing actionId", async () => {
|
|
const result = (await sdk.trigger("mem::action-get", {})) as {
|
|
success: boolean;
|
|
error: string;
|
|
};
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("actionId is required");
|
|
});
|
|
|
|
it("returns error for nonexistent action", async () => {
|
|
const result = (await sdk.trigger("mem::action-get", {
|
|
actionId: "nonexistent",
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("action not found");
|
|
});
|
|
});
|
|
});
|