import {
workitemsToolRegistry,
getWorkitemsReadOnlyToolNames,
getWorkitemsToolDefinitions,
getFilteredWorkitemsTools,
} from "../../../../src/entities/workitems/registry";
// Create mock client
const mockClient = {
request: jest.fn(),
};
// Mock GraphQL client to avoid actual API calls
jest.mock("../../../../src/services/ConnectionManager", () => ({
ConnectionManager: {
getInstance: jest.fn(() => ({
getClient: jest.fn(() => mockClient),
})),
},
}));
// Mock work item types utility
jest.mock("../../../../src/utils/workItemTypes", () => ({
getWorkItemTypes: jest.fn(() =>
Promise.resolve([
{ id: "gid://gitlab/WorkItems::Type/1", name: "Epic" },
{ id: "gid://gitlab/WorkItems::Type/2", name: "Issue" },
{ id: "gid://gitlab/WorkItems::Type/3", name: "Task" },
])
),
}));
// Mock WidgetAvailability - default: no validation failures
const mockValidateWidgetParams = jest.fn().mockReturnValue(null);
jest.mock("../../../../src/services/WidgetAvailability", () => ({
WidgetAvailability: {
validateWidgetParams: (params: Record<string, unknown>) => mockValidateWidgetParams(params),
},
}));
describe("Workitems Registry - CQRS Tools", () => {
describe("Registry Structure", () => {
it("should be a Map instance", () => {
expect(workitemsToolRegistry instanceof Map).toBe(true);
});
it("should contain exactly 2 CQRS tools", () => {
const toolNames = Array.from(workitemsToolRegistry.keys());
expect(toolNames).toContain("browse_work_items");
expect(toolNames).toContain("manage_work_item");
expect(workitemsToolRegistry.size).toBe(2);
});
it("should have tools with valid structure", () => {
for (const [toolName, tool] of workitemsToolRegistry) {
expect(tool).toHaveProperty("name", toolName);
expect(tool).toHaveProperty("description");
expect(tool).toHaveProperty("inputSchema");
expect(tool).toHaveProperty("handler");
expect(typeof tool.description).toBe("string");
expect(typeof tool.handler).toBe("function");
expect(tool.description.length).toBeGreaterThan(0);
}
});
it("should have unique tool names", () => {
const toolNames = Array.from(workitemsToolRegistry.keys());
const uniqueNames = new Set(toolNames);
expect(toolNames.length).toBe(uniqueNames.size);
});
});
describe("Tool Definitions", () => {
it("should have proper browse_work_items tool", () => {
const tool = workitemsToolRegistry.get("browse_work_items");
expect(tool).toBeDefined();
expect(tool?.name).toBe("browse_work_items");
expect(tool?.description).toContain("work items");
expect(tool?.description).toContain("list");
expect(tool?.description).toContain("get");
expect(tool?.inputSchema).toBeDefined();
});
it("should have proper manage_work_item tool", () => {
const tool = workitemsToolRegistry.get("manage_work_item");
expect(tool).toBeDefined();
expect(tool?.name).toBe("manage_work_item");
expect(tool?.description).toContain("work items");
expect(tool?.description).toContain("create");
expect(tool?.description).toContain("update");
expect(tool?.description).toContain("delete");
expect(tool?.inputSchema).toBeDefined();
});
});
describe("Read-Only Tools Function", () => {
it("should return an array of read-only tool names", () => {
const readOnlyTools = getWorkitemsReadOnlyToolNames();
expect(Array.isArray(readOnlyTools)).toBe(true);
expect(readOnlyTools.length).toBeGreaterThan(0);
});
it("should include only browse_work_items as read-only", () => {
const readOnlyTools = getWorkitemsReadOnlyToolNames();
expect(readOnlyTools).toContain("browse_work_items");
expect(readOnlyTools).toEqual(["browse_work_items"]);
});
it("should not include manage_work_item (write tool)", () => {
const readOnlyTools = getWorkitemsReadOnlyToolNames();
expect(readOnlyTools).not.toContain("manage_work_item");
});
it("should return exactly 1 read-only tool", () => {
const readOnlyTools = getWorkitemsReadOnlyToolNames();
expect(readOnlyTools.length).toBe(1);
});
it("should return tools that exist in the registry", () => {
const readOnlyTools = getWorkitemsReadOnlyToolNames();
const registryKeys = Array.from(workitemsToolRegistry.keys());
for (const toolName of readOnlyTools) {
expect(registryKeys).toContain(toolName);
}
});
});
describe("Workitems Tool Definitions Function", () => {
it("should return an array of tool definitions", () => {
const definitions = getWorkitemsToolDefinitions();
expect(Array.isArray(definitions)).toBe(true);
expect(definitions.length).toBe(workitemsToolRegistry.size);
});
it("should return all 2 CQRS tools from registry", () => {
const definitions = getWorkitemsToolDefinitions();
expect(definitions.length).toBe(2);
});
it("should return tool definitions with proper structure", () => {
const definitions = getWorkitemsToolDefinitions();
for (const definition of definitions) {
expect(definition).toHaveProperty("name");
expect(definition).toHaveProperty("description");
expect(definition).toHaveProperty("inputSchema");
expect(definition).toHaveProperty("handler");
}
});
});
describe("Filtered Workitems Tools Function", () => {
it("should return all tools in normal mode", () => {
const allTools = getFilteredWorkitemsTools(false);
const allDefinitions = getWorkitemsToolDefinitions();
expect(allTools.length).toBe(allDefinitions.length);
expect(allTools.length).toBe(2);
});
it("should return only read-only tools in read-only mode", () => {
const readOnlyTools = getFilteredWorkitemsTools(true);
const readOnlyNames = getWorkitemsReadOnlyToolNames();
expect(readOnlyTools.length).toBe(readOnlyNames.length);
expect(readOnlyTools.length).toBe(1);
});
it("should filter tools correctly in read-only mode", () => {
const readOnlyTools = getFilteredWorkitemsTools(true);
const readOnlyNames = getWorkitemsReadOnlyToolNames();
for (const tool of readOnlyTools) {
expect(readOnlyNames).toContain(tool.name);
}
});
it("should not include manage_work_item in read-only mode", () => {
const readOnlyTools = getFilteredWorkitemsTools(true);
for (const tool of readOnlyTools) {
expect(tool.name).not.toBe("manage_work_item");
}
});
});
describe("Tool Handlers", () => {
it("should have handlers that are async functions", () => {
for (const [, tool] of workitemsToolRegistry) {
expect(tool.handler.constructor.name).toBe("AsyncFunction");
}
});
it("should have handlers that accept arguments", () => {
for (const [, tool] of workitemsToolRegistry) {
expect(tool.handler.length).toBe(1); // Should accept one argument
}
});
});
describe("Registry Consistency", () => {
it("should have all expected CQRS tools", () => {
const expectedTools = ["browse_work_items", "manage_work_item"];
for (const toolName of expectedTools) {
expect(workitemsToolRegistry.has(toolName)).toBe(true);
}
});
it("should have consistent tool count between functions", () => {
const allDefinitions = getWorkitemsToolDefinitions();
const readOnlyNames = getWorkitemsReadOnlyToolNames();
const readOnlyTools = getFilteredWorkitemsTools(true);
expect(readOnlyTools.length).toBe(readOnlyNames.length);
expect(allDefinitions.length).toBe(workitemsToolRegistry.size);
expect(allDefinitions.length).toBeGreaterThan(readOnlyNames.length);
});
it("should have more tools than just read-only ones", () => {
const totalTools = workitemsToolRegistry.size;
const readOnlyCount = getWorkitemsReadOnlyToolNames().length;
expect(totalTools).toBeGreaterThan(readOnlyCount);
expect(totalTools).toBe(2);
expect(readOnlyCount).toBe(1);
});
});
describe("Tool Input Schemas", () => {
it("should have valid JSON schema structure for all tools", () => {
for (const [, tool] of workitemsToolRegistry) {
expect(tool.inputSchema).toBeDefined();
expect(typeof tool.inputSchema).toBe("object");
// CQRS tools use discriminated unions which produce "anyOf" in JSON schema
const schema = tool.inputSchema;
const hasValidStructure = "type" in schema || "anyOf" in schema || "oneOf" in schema;
expect(hasValidStructure).toBe(true);
}
});
it("should have consistent schema format", () => {
for (const [toolName, tool] of workitemsToolRegistry) {
expect(tool.inputSchema).toBeDefined();
if (typeof tool.inputSchema === "object" && tool.inputSchema !== null) {
const schema = tool.inputSchema;
const hasValidStructure = "type" in schema || "anyOf" in schema || "oneOf" in schema;
expect(hasValidStructure).toBe(true);
} else {
throw new Error(`Tool ${toolName} has invalid inputSchema type`);
}
}
});
});
describe("Handler Tests", () => {
beforeEach(() => {
mockClient.request.mockReset();
mockValidateWidgetParams.mockClear();
});
// Helper function to create complete mock work items
const createMockWorkItem = (overrides: Record<string, unknown> = {}) => ({
id: "gid://gitlab/WorkItem/1",
iid: "1",
title: "Test Work Item",
state: "OPEN",
workItemType: { id: "gid://gitlab/WorkItems::Type/8", name: "Epic" },
webUrl: "https://gitlab.example.com/groups/test/-/epics/1",
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
description: null,
widgets: [],
...overrides,
});
describe("browse_work_items handler - list action", () => {
it("should execute list action successfully with valid namespace path", async () => {
const mockWorkItems = [
createMockWorkItem({ id: "gid://gitlab/WorkItem/1", iid: "1", title: "Epic 1" }),
createMockWorkItem({ id: "gid://gitlab/WorkItem/2", iid: "2", title: "Epic 2" }),
];
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Group",
workItems: {
nodes: mockWorkItems,
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = await tool?.handler({ action: "list", namespace: "test-group" });
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
namespacePath: "test-group",
types: undefined,
first: 20,
after: undefined,
});
// With simple=true (default), expect simplified structure with converted IDs
expect(result).toHaveProperty("items");
expect(result).toHaveProperty("hasMore", false);
expect(result).toHaveProperty("endCursor", null);
expect(Array.isArray((result as { items: unknown[] }).items)).toBe(true);
expect((result as { items: unknown[] }).items.length).toBe(2);
});
it("should return items array structure with list action", async () => {
const mockWorkItems = [
createMockWorkItem({
description: "Test epic description",
widgets: [
{
type: "ASSIGNEES",
assignees: { nodes: [{ id: "user1", username: "test", name: "Test User" }] },
},
],
}),
];
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Group",
workItems: {
nodes: mockWorkItems,
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = await tool?.handler({
action: "list",
namespace: "test-group",
simple: false,
});
expect(result).toHaveProperty("items");
expect(result).toHaveProperty("hasMore");
expect(result).toHaveProperty("endCursor");
expect(Array.isArray((result as { items: unknown[] }).items)).toBe(true);
});
it("should handle custom pagination parameters in list action", async () => {
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Group",
workItems: {
nodes: [],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
await tool?.handler({
action: "list",
namespace: "test-group",
first: 50,
after: "cursor-123",
});
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
namespacePath: "test-group",
types: undefined,
first: 50,
after: "cursor-123",
});
});
it("should return empty array when group has no work items", async () => {
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Group",
workItems: {
nodes: [],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = await tool?.handler({ action: "list", namespace: "empty-group" });
expect(result).toEqual({
items: [],
hasMore: false,
endCursor: null,
});
});
it("should handle types parameter in list action", async () => {
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Group",
workItems: {
nodes: [],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
await tool?.handler({
action: "list",
namespace: "test-group",
types: ["EPIC", "ISSUE"],
});
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
namespacePath: "test-group",
types: ["EPIC", "ISSUE"],
first: 20,
after: undefined,
});
});
it("should validate required parameters for list action", async () => {
const tool = workitemsToolRegistry.get("browse_work_items");
// Missing namespace should throw validation error
await expect(tool?.handler({ action: "list" })).rejects.toThrow();
});
it("should use simplified structure when simple=true", async () => {
const mockWorkItem = createMockWorkItem({
workItemType: { name: "Issue" },
description: "Test description",
widgets: [
{
type: "ASSIGNEES",
assignees: {
nodes: [{ id: "gid://gitlab/User/1", username: "test", name: "Test User" }],
},
},
{
type: "LABELS",
labels: {
nodes: [{ id: "gid://gitlab/ProjectLabel/1", title: "bug", color: "#ff0000" }],
},
},
],
});
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Project",
workItems: {
nodes: [mockWorkItem],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = (await tool?.handler({
action: "list",
namespace: "test-project",
simple: true,
})) as { items: Array<Record<string, unknown>> };
expect(result.items[0]).toMatchObject({
id: "1",
iid: "1",
title: "Test Work Item",
state: "OPEN",
workItemType: "Issue",
});
});
it("should truncate long descriptions in simplified mode", async () => {
const longDescription = "A".repeat(250);
const mockWorkItem = createMockWorkItem({
workItemType: { name: "Issue" },
description: longDescription,
widgets: [],
});
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Project",
workItems: {
nodes: [mockWorkItem],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = (await tool?.handler({
action: "list",
namespace: "test-project",
simple: true,
})) as { items: Array<{ description: string }> };
expect(result.items[0].description).toBe("A".repeat(200) + "...");
});
it("should include MILESTONE widget in simplified mode", async () => {
const mockWorkItem = createMockWorkItem({
workItemType: { name: "Issue" },
widgets: [
{
type: "MILESTONE",
milestone: {
id: "gid://gitlab/Milestone/5",
title: "v1.0",
state: "active",
},
},
],
});
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Project",
workItems: {
nodes: [mockWorkItem],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = (await tool?.handler({
action: "list",
namespace: "test-project",
simple: true,
})) as { items: Array<{ widgets?: Array<{ type: string; milestone?: unknown }> }> };
expect(result.items[0].widgets).toBeDefined();
// IDs are cleaned from GIDs to simple IDs
expect(result.items[0].widgets).toContainEqual({
type: "MILESTONE",
milestone: {
id: "5",
title: "v1.0",
state: "active",
},
});
});
it("should include HIERARCHY widget with parent in simplified mode", async () => {
const mockWorkItem = createMockWorkItem({
workItemType: { name: "Task" },
widgets: [
{
type: "HIERARCHY",
parent: {
id: "gid://gitlab/WorkItem/100",
iid: "10",
title: "Parent Issue",
workItemType: "Issue",
},
hasChildren: false,
},
],
});
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Project",
workItems: {
nodes: [mockWorkItem],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = (await tool?.handler({
action: "list",
namespace: "test-project",
simple: true,
})) as {
items: Array<{
widgets?: Array<{ type: string; parent?: unknown; hasChildren?: boolean }>;
}>;
};
expect(result.items[0].widgets).toBeDefined();
// IDs are cleaned from GIDs to simple IDs
expect(result.items[0].widgets).toContainEqual({
type: "HIERARCHY",
parent: {
id: "100",
iid: "10",
title: "Parent Issue",
workItemType: "Issue",
},
hasChildren: false,
});
});
it("should include HIERARCHY widget with hasChildren in simplified mode", async () => {
const mockWorkItem = createMockWorkItem({
workItemType: { name: "Epic" },
widgets: [
{
type: "HIERARCHY",
parent: null,
hasChildren: true,
},
],
});
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Group",
workItems: {
nodes: [mockWorkItem],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = (await tool?.handler({
action: "list",
namespace: "test-group",
simple: true,
})) as {
items: Array<{
widgets?: Array<{ type: string; parent?: unknown; hasChildren?: boolean }>;
}>;
};
expect(result.items[0].widgets).toBeDefined();
expect(result.items[0].widgets).toContainEqual({
type: "HIERARCHY",
parent: null,
hasChildren: true,
});
});
it("should include TIME_TRACKING widget in simplified mode", async () => {
const mockWorkItem = createMockWorkItem({
workItemType: { name: "Issue" },
widgets: [
{
type: "TIME_TRACKING",
timeEstimate: 7200,
totalTimeSpent: 1800,
},
],
});
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Project",
workItems: {
nodes: [mockWorkItem],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = (await tool?.handler({
action: "list",
namespace: "test-project",
simple: true,
})) as {
items: Array<{ widgets?: Array<{ type: string; timeEstimate?: number }> }>;
};
expect(result.items[0].widgets).toBeDefined();
expect(result.items[0].widgets).toContainEqual({
type: "TIME_TRACKING",
timeEstimate: 7200,
totalTimeSpent: 1800,
});
});
it("should include TIME_TRACKING when only totalTimeSpent is set", async () => {
const mockWorkItem = createMockWorkItem({
workItemType: { name: "Issue" },
widgets: [
{
type: "TIME_TRACKING",
totalTimeSpent: 900,
},
],
});
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Project",
workItems: {
nodes: [mockWorkItem],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = (await tool?.handler({
action: "list",
namespace: "test-project",
simple: true,
})) as {
items: Array<{ widgets?: Array<{ type: string; totalTimeSpent?: number }> }>;
};
expect(result.items[0].widgets).toBeDefined();
expect(result.items[0].widgets).toContainEqual({
type: "TIME_TRACKING",
timeEstimate: undefined,
totalTimeSpent: 900,
});
});
it("should omit TIME_TRACKING when no values are set", async () => {
const mockWorkItem = createMockWorkItem({
workItemType: { name: "Issue" },
widgets: [
{
type: "TIME_TRACKING",
},
],
});
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Project",
workItems: {
nodes: [mockWorkItem],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = (await tool?.handler({
action: "list",
namespace: "test-project",
simple: true,
})) as {
items: Array<{ widgets?: Array<{ type: string }> }>;
};
const widgets = result.items[0].widgets || [];
expect(widgets.some(widget => widget.type === "TIME_TRACKING")).toBe(false);
});
it("should filter by state parameter", async () => {
const mockWorkItems = [
createMockWorkItem({ id: "gid://gitlab/WorkItem/1", state: "OPEN" }),
createMockWorkItem({ id: "gid://gitlab/WorkItem/2", state: "CLOSED" }),
];
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: "Group",
workItems: {
nodes: mockWorkItems,
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = (await tool?.handler({
action: "list",
namespace: "test-group",
state: ["OPEN"],
})) as { items: unknown[] };
// Client-side filtering should only return OPEN items
expect(result.items.length).toBe(1);
});
});
describe("browse_work_items handler - get action", () => {
it("should execute get action successfully with valid work item ID", async () => {
const mockWorkItem = createMockWorkItem({
title: "Test Work Item",
description: "Test description",
});
mockClient.request.mockResolvedValueOnce({
workItem: mockWorkItem,
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = await tool?.handler({ action: "get", id: "1" });
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
id: "gid://gitlab/WorkItem/1",
});
expect(result).toMatchObject({
id: "1",
title: "Test Work Item",
description: "Test description",
});
});
it("should handle non-existent work item in get action", async () => {
mockClient.request.mockResolvedValueOnce({ workItem: null });
const tool = workitemsToolRegistry.get("browse_work_items");
await expect(
tool?.handler({ action: "get", id: "gid://gitlab/WorkItem/999" })
).rejects.toThrow('Work item with ID "gid://gitlab/WorkItem/999" not found');
});
it("should validate required id parameter for get action", async () => {
const tool = workitemsToolRegistry.get("browse_work_items");
// Missing id should throw validation error
await expect(tool?.handler({ action: "get" })).rejects.toThrow();
});
// --- IID lookup tests (new functionality) ---
it("should execute get action successfully with namespace + iid", async () => {
const mockWorkItem = createMockWorkItem({
title: "Issue from URL",
description: "Found by IID",
iid: "95",
});
mockClient.request.mockResolvedValueOnce({
namespace: {
workItem: mockWorkItem,
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
const result = await tool?.handler({
action: "get",
namespace: "group/project",
iid: "95",
});
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
namespacePath: "group/project",
iid: "95",
});
expect(result).toMatchObject({
iid: "95",
title: "Issue from URL",
description: "Found by IID",
});
});
it("should handle non-existent work item when using namespace + iid", async () => {
mockClient.request.mockResolvedValueOnce({
namespace: { workItem: null },
});
const tool = workitemsToolRegistry.get("browse_work_items");
await expect(
tool?.handler({
action: "get",
namespace: "group/project",
iid: "999",
})
).rejects.toThrow('Work item with IID "999" not found in namespace "group/project"');
});
it("should handle null namespace when using namespace + iid", async () => {
mockClient.request.mockResolvedValueOnce({
namespace: null,
});
const tool = workitemsToolRegistry.get("browse_work_items");
await expect(
tool?.handler({
action: "get",
namespace: "invalid/namespace",
iid: "123",
})
).rejects.toThrow('Work item with IID "123" not found in namespace "invalid/namespace"');
});
it("should reject get action when only namespace is provided without iid", async () => {
const tool = workitemsToolRegistry.get("browse_work_items");
await expect(
tool?.handler({
action: "get",
namespace: "group/project",
})
).rejects.toThrow();
});
it("should reject get action when only iid is provided without namespace", async () => {
const tool = workitemsToolRegistry.get("browse_work_items");
await expect(
tool?.handler({
action: "get",
iid: "95",
})
).rejects.toThrow();
});
it("should prefer namespace + iid lookup when both methods are provided", async () => {
const mockWorkItem = createMockWorkItem({
title: "Found by IID",
iid: "95",
});
mockClient.request.mockResolvedValueOnce({
namespace: {
workItem: mockWorkItem,
},
});
const tool = workitemsToolRegistry.get("browse_work_items");
await tool?.handler({
action: "get",
namespace: "group/project",
iid: "95",
id: "12345", // This should be ignored when namespace+iid is provided
});
// Should call the IID query, not the ID query
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
namespacePath: "group/project",
iid: "95",
});
});
});
describe("manage_work_item handler - create action", () => {
it("should execute create action successfully with valid parameters", async () => {
const createdWorkItem = {
id: "gid://gitlab/WorkItem/123",
title: "New Epic",
workItemType: { name: "EPIC" },
};
mockClient.request.mockResolvedValueOnce({
workItemCreate: {
workItem: createdWorkItem,
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = await tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "EPIC",
title: "New Epic",
});
expect(mockClient.request).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
id: "123",
title: "New Epic",
workItemType: "EPIC",
});
});
it("should create work item with description", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: {
workItem: {
id: "gid://gitlab/WorkItem/124",
title: "Epic with Description",
description: "Detailed description",
workItemType: { name: "EPIC" },
},
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "EPIC",
title: "Epic with Description",
description: "Detailed description",
});
expect(mockClient.request).toHaveBeenCalledTimes(1);
});
it("should create work item with assignees", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: {
workItem: {
id: "gid://gitlab/WorkItem/125",
title: "Epic with Assignees",
workItemType: { name: "EPIC" },
},
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "EPIC",
title: "Epic with Assignees",
assigneeIds: ["1", "2"],
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
assigneesWidget: { assigneeIds: ["gid://gitlab/User/1", "gid://gitlab/User/2"] },
}),
})
);
});
it("should create work item with labels", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: {
workItem: {
id: "gid://gitlab/WorkItem/126",
title: "Epic with Labels",
workItemType: { name: "EPIC" },
},
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "EPIC",
title: "Epic with Labels",
labelIds: ["10", "20"],
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
labelsWidget: {
labelIds: ["gid://gitlab/ProjectLabel/10", "gid://gitlab/ProjectLabel/20"],
},
}),
})
);
});
it("should create work item with milestone", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: {
workItem: {
id: "gid://gitlab/WorkItem/127",
title: "Epic with Milestone",
workItemType: { name: "EPIC" },
},
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "EPIC",
title: "Epic with Milestone",
milestoneId: "5",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
milestoneWidget: { milestoneId: "gid://gitlab/Milestone/5" },
}),
})
);
});
it("should handle invalid work item type in create action", async () => {
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "INVALID_TYPE",
title: "Failed Epic",
})
).rejects.toThrow();
});
it("should handle work item type not found error", async () => {
const tool = workitemsToolRegistry.get("manage_work_item");
// INCIDENT is schema-valid but not in our mocked getWorkItemTypes
await expect(
tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "INCIDENT",
title: "Test Epic",
})
).rejects.toThrow('Work item type "INCIDENT" not found in namespace "test-group"');
});
it("should handle GraphQL errors in create action", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: {
workItem: null,
errors: ["Validation failed", "Title is required"],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "EPIC",
title: "",
})
).rejects.toThrow("GitLab GraphQL errors: Validation failed, Title is required");
});
it("should handle empty work item creation response", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: {
workItem: null,
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "EPIC",
title: "Test Epic",
})
).rejects.toThrow("Work item creation failed - no work item returned");
});
it("should throw VERSION_RESTRICTED error when widget validation fails", async () => {
// Mock validateWidgetParams to return a failure for this test
mockValidateWidgetParams.mockReturnValueOnce({
parameter: "weight",
widget: "WEIGHT",
requiredVersion: "15.0",
detectedVersion: "14.0.0",
requiredTier: "premium",
currentTier: "free",
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "EPIC",
title: "Test Epic",
description: "test",
})
).rejects.toThrow("Widget 'WEIGHT'");
});
it("should not validate empty arrays on create (no widget sent for empty arrays)", async () => {
// Empty arrays should NOT be passed to validateWidgetParams on create,
// because the handler only sends widget input for non-empty arrays.
mockClient.request.mockResolvedValueOnce({
workItemCreate: {
workItem: {
id: "gid://gitlab/WorkItem/999",
title: "Test",
workItemType: { name: "EPIC" },
},
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "test-group",
workItemType: "EPIC",
title: "Test",
assigneeIds: [],
labelIds: [],
});
// validateWidgetParams should have been called WITHOUT assigneeIds/labelIds
expect(mockValidateWidgetParams).toHaveBeenLastCalledWith(
expect.not.objectContaining({ assigneeIds: [], labelIds: [] })
);
});
});
describe("manage_work_item handler - update action", () => {
it("should execute update action successfully with valid parameters", async () => {
const updatedWorkItem = {
id: "gid://gitlab/WorkItem/123",
title: "Updated Epic",
};
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: updatedWorkItem,
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = await tool?.handler({
action: "update",
id: "123",
title: "Updated Epic",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
id: "gid://gitlab/WorkItem/123",
title: "Updated Epic",
}),
})
);
expect(result).toMatchObject({
id: "123",
title: "Updated Epic",
});
});
it("should handle update with multiple fields", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: { id: "gid://gitlab/WorkItem/123" },
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "gid://gitlab/WorkItem/123",
title: "Updated Title",
description: "Updated description",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
id: "gid://gitlab/WorkItem/123",
title: "Updated Title",
descriptionWidget: { description: "Updated description" },
}),
})
);
});
it("should handle update with state change", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: { id: "gid://gitlab/WorkItem/123", state: "CLOSED" },
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "123",
state: "CLOSE",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
id: "gid://gitlab/WorkItem/123",
stateEvent: "CLOSE",
}),
})
);
});
it("should handle update with assignees", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: { id: "gid://gitlab/WorkItem/123" },
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "123",
assigneeIds: ["1", "2"],
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
assigneesWidget: { assigneeIds: ["gid://gitlab/User/1", "gid://gitlab/User/2"] },
}),
})
);
});
it("should handle update with labels (replace mode via labelIds)", async () => {
// labelIds replaces ALL labels - requires fetching current labels first
// Mock GET_WORK_ITEM to return current labels
mockClient.request.mockResolvedValueOnce({
workItem: {
id: "gid://gitlab/WorkItem/123",
widgets: [
{
type: "LABELS",
labels: {
nodes: [
{ id: "gid://gitlab/ProjectLabel/existing1" },
{ id: "gid://gitlab/ProjectLabel/existing2" },
],
},
},
],
},
});
// Mock workItemUpdate
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: { id: "gid://gitlab/WorkItem/123" },
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "123",
labelIds: ["10", "20"],
});
// First call should be GET_WORK_ITEM
expect(mockClient.request).toHaveBeenCalledTimes(2);
// Second call should be workItemUpdate with addLabelIds and removeLabelIds
expect(mockClient.request).toHaveBeenLastCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
labelsWidget: {
removeLabelIds: [
"gid://gitlab/ProjectLabel/existing1",
"gid://gitlab/ProjectLabel/existing2",
],
addLabelIds: ["gid://gitlab/ProjectLabel/10", "gid://gitlab/ProjectLabel/20"],
},
}),
})
);
});
it("should handle update with addLabelIds (incremental add)", async () => {
// addLabelIds adds labels incrementally without removing existing ones
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: { id: "gid://gitlab/WorkItem/123" },
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "123",
addLabelIds: ["10", "20"],
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
labelsWidget: {
addLabelIds: ["gid://gitlab/ProjectLabel/10", "gid://gitlab/ProjectLabel/20"],
},
}),
})
);
});
it("should handle update with removeLabelIds (incremental remove)", async () => {
// removeLabelIds removes specific labels without affecting others
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: { id: "gid://gitlab/WorkItem/123" },
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "123",
removeLabelIds: ["30"],
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
labelsWidget: {
removeLabelIds: ["gid://gitlab/ProjectLabel/30"],
},
}),
})
);
});
it("should handle update with both addLabelIds and removeLabelIds", async () => {
// Can add and remove labels in the same operation
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: { id: "gid://gitlab/WorkItem/123" },
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "123",
addLabelIds: ["10"],
removeLabelIds: ["20"],
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
labelsWidget: {
addLabelIds: ["gid://gitlab/ProjectLabel/10"],
removeLabelIds: ["gid://gitlab/ProjectLabel/20"],
},
}),
})
);
});
it("should reject labelIds with addLabelIds (mutually exclusive)", async () => {
// labelIds (replace) cannot be used with addLabelIds (incremental)
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "update",
id: "123",
labelIds: ["10"],
addLabelIds: ["20"],
})
).rejects.toThrow(/labelIds.*cannot be used together with addLabelIds or removeLabelIds/);
});
it("should reject labelIds with removeLabelIds (mutually exclusive)", async () => {
// labelIds (replace) cannot be used with removeLabelIds (incremental)
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "update",
id: "123",
labelIds: ["10"],
removeLabelIds: ["20"],
})
).rejects.toThrow(/labelIds.*cannot be used together with addLabelIds or removeLabelIds/);
});
it("should throw error when same label in both addLabelIds and removeLabelIds", async () => {
// Intersection validation: cannot add and remove the same label
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "update",
id: "123",
addLabelIds: ["10", "20"],
removeLabelIds: ["20", "30"],
})
).rejects.toThrow(
/Invalid label operation: cannot add and remove the same labels simultaneously/
);
});
it("should handle update with milestone", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: { id: "gid://gitlab/WorkItem/123" },
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "123",
milestoneId: "5",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
input: expect.objectContaining({
milestoneWidget: { milestoneId: "gid://gitlab/Milestone/5" },
}),
})
);
});
it("should handle GraphQL errors in update action", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: null,
errors: ["Permission denied", "Work item not found"],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "update",
id: "gid://gitlab/WorkItem/999",
title: "Updated Title",
})
).rejects.toThrow("GitLab GraphQL errors: Permission denied, Work item not found");
});
it("should handle empty update response", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: null,
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "update",
id: "gid://gitlab/WorkItem/123",
title: "Updated Title",
})
).rejects.toThrow("Work item update failed - no work item returned");
});
it("should throw VERSION_RESTRICTED error when widget validation fails", async () => {
// Mock validateWidgetParams to return a failure for this test
mockValidateWidgetParams.mockReturnValueOnce({
parameter: "healthStatus",
widget: "HEALTH_STATUS",
requiredVersion: "15.0",
detectedVersion: "15.5.0",
requiredTier: "ultimate",
currentTier: "premium",
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "update",
id: "123",
description: "test",
})
).rejects.toThrow("Widget 'HEALTH_STATUS'");
});
});
describe("manage_work_item handler - delete action", () => {
it("should execute delete action successfully with valid work item ID", async () => {
mockClient.request.mockResolvedValueOnce({
workItemDelete: { errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = await tool?.handler({ action: "delete", id: "gid://gitlab/WorkItem/123" });
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
id: "gid://gitlab/WorkItem/123",
});
expect(result).toEqual({ deleted: true });
});
it("should handle delete with simple ID", async () => {
mockClient.request.mockResolvedValueOnce({
workItemDelete: { errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = await tool?.handler({ action: "delete", id: "123" });
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
id: "gid://gitlab/WorkItem/123",
});
expect(result).toEqual({ deleted: true });
});
it("should handle deletion errors", async () => {
mockClient.request.mockRejectedValueOnce(new Error("Deletion failed"));
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({ action: "delete", id: "gid://gitlab/WorkItem/123" })
).rejects.toThrow("Deletion failed");
});
it("should handle GraphQL errors in delete action", async () => {
mockClient.request.mockResolvedValueOnce({
workItemDelete: {
errors: ["Permission denied", "Work item cannot be deleted"],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "delete",
id: "gid://gitlab/WorkItem/123",
})
).rejects.toThrow("GitLab GraphQL errors: Permission denied, Work item cannot be deleted");
});
});
describe("manage_work_item handler - delete_timelog action", () => {
it("should delete a timelog entry by GID", async () => {
mockClient.request.mockResolvedValueOnce({
timelogDelete: {
timelog: {
id: "gid://gitlab/Timelog/7",
timeSpent: 3600,
spentAt: "2025-01-15T00:00:00Z",
summary: "Bug investigation",
},
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = await tool?.handler({
action: "delete_timelog",
timelogId: "gid://gitlab/Timelog/7",
});
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
id: "gid://gitlab/Timelog/7",
});
expect(result).toEqual({
deleted: true,
timelog: {
id: "gid://gitlab/Timelog/7",
timeSpent: 3600,
spentAt: "2025-01-15T00:00:00Z",
summary: "Bug investigation",
},
});
});
it("should handle simple numeric timelog ID", async () => {
mockClient.request.mockResolvedValueOnce({
timelogDelete: {
timelog: {
id: "gid://gitlab/Timelog/42",
timeSpent: 1800,
spentAt: "2025-02-01T00:00:00Z",
summary: null,
},
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = await tool?.handler({
action: "delete_timelog",
timelogId: "42",
});
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
id: "gid://gitlab/Timelog/42",
});
expect(result).toEqual({
deleted: true,
timelog: {
id: "gid://gitlab/Timelog/42",
timeSpent: 1800,
spentAt: "2025-02-01T00:00:00Z",
summary: null,
},
});
});
it("should handle GraphQL errors in delete_timelog", async () => {
mockClient.request.mockResolvedValueOnce({
timelogDelete: {
timelog: null,
errors: ["Timelog not found"],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "delete_timelog",
timelogId: "gid://gitlab/Timelog/999",
})
).rejects.toThrow("GitLab GraphQL errors: Timelog not found");
});
it("should handle permission denied error", async () => {
mockClient.request.mockResolvedValueOnce({
timelogDelete: {
timelog: null,
errors: [
"The resource that you are attempting to access does not exist or you don't have permission to perform this action",
],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "delete_timelog",
timelogId: "gid://gitlab/Timelog/7",
})
).rejects.toThrow("The resource that you are attempting to access does not exist");
});
it("should handle network/request errors", async () => {
mockClient.request.mockRejectedValueOnce(new Error("Network error"));
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "delete_timelog",
timelogId: "gid://gitlab/Timelog/7",
})
).rejects.toThrow("Network error");
});
it("should return null timelog when response has no timelog data", async () => {
mockClient.request.mockResolvedValueOnce({
timelogDelete: {
timelog: null,
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = await tool?.handler({
action: "delete_timelog",
timelogId: "gid://gitlab/Timelog/7",
});
expect(result).toEqual({
deleted: true,
timelog: null,
});
});
});
describe("manage_work_item handler - add_link action", () => {
it("should add a BLOCKS link between work items", async () => {
mockClient.request.mockResolvedValueOnce({
workItemAddLinkedItems: {
workItem: {
id: "gid://gitlab/WorkItem/100",
iid: "10",
title: "Source Item",
state: "OPEN",
workItemType: { id: "gid://gitlab/WorkItems::Type/2", name: "Issue" },
webUrl: "https://gitlab.com/group/project/-/work_items/10",
widgets: [
{
type: "LINKED_ITEMS",
linkedItems: {
nodes: [
{
linkType: "BLOCKS",
workItem: {
id: "gid://gitlab/WorkItem/200",
iid: "20",
title: "Target",
state: "OPEN",
workItemType: { name: "Issue" },
},
},
],
},
},
],
},
errors: [],
message: null,
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = await tool?.handler({
action: "add_link",
id: "100",
targetId: "200",
linkType: "BLOCKS",
});
expect(mockClient.request).toHaveBeenCalledWith(expect.anything(), {
input: {
id: "gid://gitlab/WorkItem/100",
workItemsIds: ["gid://gitlab/WorkItem/200"],
linkType: "BLOCKS",
},
});
expect(result).toHaveProperty("id");
});
it("should handle GraphQL errors in add_link action", async () => {
mockClient.request.mockResolvedValueOnce({
workItemAddLinkedItems: {
workItem: null,
errors: ["Work items are already linked"],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({ action: "add_link", id: "100", targetId: "200", linkType: "BLOCKS" })
).rejects.toThrow("GitLab GraphQL errors: Work items are already linked");
});
it("should handle empty response in add_link action", async () => {
mockClient.request.mockResolvedValueOnce({
workItemAddLinkedItems: { workItem: null, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "add_link",
id: "100",
targetId: "200",
linkType: "BLOCKED_BY",
})
).rejects.toThrow("Add linked item failed - no work item returned");
// Verify BLOCKED_BY is passed directly to GraphQL API without mapping
expect(mockClient.request).toHaveBeenCalledWith(expect.anything(), {
input: {
id: "gid://gitlab/WorkItem/100",
workItemsIds: ["gid://gitlab/WorkItem/200"],
linkType: "BLOCKED_BY",
},
});
});
});
describe("manage_work_item handler - remove_link action", () => {
it("should remove a link between work items", async () => {
mockClient.request.mockResolvedValueOnce({
workItemRemoveLinkedItems: {
workItem: {
id: "gid://gitlab/WorkItem/100",
iid: "10",
title: "Source",
state: "OPEN",
workItemType: { id: "gid://gitlab/WorkItems::Type/2", name: "Issue" },
webUrl: "https://gitlab.com/-/work_items/10",
widgets: [{ type: "LINKED_ITEMS", linkedItems: { nodes: [] } }],
},
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = await tool?.handler({
action: "remove_link",
id: "100",
targetId: "200",
// Note: linkType is NOT sent to GitLab API - links identified by source+target IDs only
});
expect(mockClient.request).toHaveBeenCalledWith(expect.anything(), {
input: {
id: "gid://gitlab/WorkItem/100",
workItemsIds: ["gid://gitlab/WorkItem/200"],
// No linkType - GitLab API doesn't accept it for remove
},
});
expect(result).toHaveProperty("id");
});
it("should handle GraphQL errors in remove_link action", async () => {
mockClient.request.mockResolvedValueOnce({
workItemRemoveLinkedItems: {
workItem: null,
errors: ["Link not found"],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({ action: "remove_link", id: "100", targetId: "200" })
).rejects.toThrow("GitLab GraphQL errors: Link not found");
});
it("should throw when remove_link returns no work item", async () => {
mockClient.request.mockResolvedValueOnce({
workItemRemoveLinkedItems: {
workItem: null,
errors: [],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({ action: "remove_link", id: "100", targetId: "200" })
).rejects.toThrow("Remove linked item failed - no work item returned");
});
});
describe("manage_work_item handler - widget parameters", () => {
const mockWorkItemResponse = {
id: "gid://gitlab/WorkItem/100",
iid: "10",
title: "Test Item",
state: "OPEN",
workItemType: { id: "gid://gitlab/WorkItems::Type/2", name: "Issue" },
webUrl: "https://gitlab.com/-/work_items/10",
widgets: [],
};
it("should create work item with start and due dates", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "group/project",
title: "Dated Item",
workItemType: "ISSUE",
startDate: "2025-01-15",
dueDate: "2025-02-28",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
startAndDueDateWidget: { startDate: "2025-01-15", dueDate: "2025-02-28" },
}),
})
);
});
it("should create work item with hierarchy (parentId)", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "group/project",
title: "Child Item",
workItemType: "TASK",
parentId: "500",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
hierarchyWidget: { parentId: "gid://gitlab/WorkItem/500" },
}),
})
);
});
it("should create work item with time estimate via two-step approach", async () => {
// Step 1: Create work item (without timeTrackingWidget - not supported by GitLab API)
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
// Step 2: Update with timeEstimate
const updatedWorkItemResponse = {
...mockWorkItemResponse,
widgets: [
{
type: "TIME_TRACKING",
timeEstimate: 14400, // 4 hours in seconds
totalTimeSpent: 0,
},
],
};
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: updatedWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = await tool?.handler({
action: "create",
namespace: "group/project",
title: "Estimated Item",
workItemType: "ISSUE",
timeEstimate: "4h",
});
// Verify two API calls were made: create (without timeTrackingWidget) + update
expect(mockClient.request).toHaveBeenCalledTimes(2);
// First call: create WITHOUT timeTrackingWidget
expect(mockClient.request).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({
input: expect.not.objectContaining({
timeTrackingWidget: expect.anything(),
}),
})
);
// Second call: update WITH timeTrackingWidget
expect(mockClient.request).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
id: "gid://gitlab/WorkItem/100",
timeTrackingWidget: { timeEstimate: "4h" },
}),
})
);
// Result should be the updated work item
expect(result).toMatchObject({
id: "100",
});
});
it("should return partial success when timeEstimate update fails with errors", async () => {
// Step 1: Create succeeds
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
// Step 2: Update fails with GraphQL errors
mockClient.request.mockResolvedValueOnce({
workItemUpdate: {
workItem: null,
errors: ["Time tracking update not allowed"],
},
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = (await tool?.handler({
action: "create",
namespace: "group/project",
title: "Estimated Item",
workItemType: "ISSUE",
timeEstimate: "4h",
})) as { id: string; _warning?: { message: string; failedProperties: unknown } };
// Work item should still be created
expect(result.id).toBe("100");
// Should include warning about failed timeEstimate
expect(result._warning).toBeDefined();
expect(result._warning?.message).toContain("could not be applied");
expect(result._warning?.failedProperties).toHaveProperty("timeEstimate");
});
it("should return partial success when timeEstimate update throws exception", async () => {
// Step 1: Create succeeds
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
// Step 2: Update throws exception
mockClient.request.mockRejectedValueOnce(new Error("Network timeout"));
const tool = workitemsToolRegistry.get("manage_work_item");
const result = (await tool?.handler({
action: "create",
namespace: "group/project",
title: "Estimated Item",
workItemType: "ISSUE",
timeEstimate: "4h",
})) as { id: string; _warning?: { message: string; failedProperties: unknown } };
// Work item should still be created
expect(result.id).toBe("100");
// Should include warning about failed timeEstimate
expect(result._warning).toBeDefined();
expect(result._warning?.failedProperties).toHaveProperty("timeEstimate");
});
it("should return partial success when timeEstimate update returns empty workItem", async () => {
// Step 1: Create succeeds
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
// Step 2: Update returns no work item but also no errors (edge case)
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: null, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
const result = (await tool?.handler({
action: "create",
namespace: "group/project",
title: "Estimated Item",
workItemType: "ISSUE",
timeEstimate: "4h",
})) as {
id: string;
_warning?: { message: string; failedProperties: { timeEstimate: { error: string } } };
};
// Work item should still be created
expect(result.id).toBe("100");
// Should include warning about failed timeEstimate
expect(result._warning).toBeDefined();
expect(result._warning?.message).toContain("could not be applied");
expect(result._warning?.failedProperties.timeEstimate.error).toContain(
"returned no work item"
);
});
it("should create work item with weight", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "group/project",
title: "Weighted",
workItemType: "ISSUE",
weight: 5,
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
weightWidget: { weight: 5 },
}),
})
);
});
it("should create work item with iteration", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "group/project",
title: "Sprint Item",
workItemType: "ISSUE",
iterationId: "42",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
iterationWidget: { iterationId: "gid://gitlab/Iteration/42" },
}),
})
);
});
it("should create work item with health status", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "group/project",
title: "At Risk",
workItemType: "ISSUE",
healthStatus: "atRisk",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
healthStatusWidget: { healthStatus: "atRisk" },
}),
})
);
});
it("should create work item with color", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "my-group",
title: "Colored Epic",
workItemType: "EPIC",
color: "#FF5733",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
colorWidget: { color: "#FF5733" },
}),
})
);
});
it("should create work item with progress", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "group/project",
title: "Key Result",
workItemType: "ISSUE",
progressCurrentValue: 50,
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
progressWidget: { currentValue: 50 },
}),
})
);
});
it("should create work item with childrenIds", async () => {
mockClient.request.mockResolvedValueOnce({
workItemCreate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "create",
namespace: "group/project",
title: "Parent Item",
workItemType: "ISSUE",
childrenIds: ["201", "202"],
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
hierarchyWidget: {
childrenIds: ["gid://gitlab/WorkItem/201", "gid://gitlab/WorkItem/202"],
},
}),
})
);
});
it("should update work item with weight", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "100",
weight: 8,
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
weightWidget: { weight: 8 },
}),
})
);
});
it("should update work item with healthStatus", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "100",
healthStatus: "atRisk",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
healthStatusWidget: { healthStatus: "atRisk" },
}),
})
);
});
it("should update work item with progressCurrentValue", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "100",
progressCurrentValue: 75,
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
progressWidget: { currentValue: 75 },
}),
})
);
});
it("should update work item with color", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "100",
color: "#00FF00",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
colorWidget: { color: "#00FF00" },
}),
})
);
});
it("should update work item with time tracking (estimate + timelog)", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "100",
timeEstimate: "8h",
timeSpent: "2h 30m",
timeSpentAt: "2025-01-20T10:00:00Z",
timeSpentSummary: "Code review",
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
timeTrackingWidget: {
timeEstimate: "8h",
timelog: {
timeSpent: "2h 30m",
spentAt: "2025-01-20T10:00:00Z",
summary: "Code review",
},
},
}),
})
);
});
it("should throw error if timeSpentAt provided without timeSpent", async () => {
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "update",
id: "100",
timeSpentAt: "2025-01-20T10:00:00Z",
})
).rejects.toThrow("timeSpentAt and timeSpentSummary require timeSpent");
});
it("should throw error if timeSpentSummary provided without timeSpent", async () => {
const tool = workitemsToolRegistry.get("manage_work_item");
await expect(
tool?.handler({
action: "update",
id: "100",
timeSpentSummary: "Some work",
})
).rejects.toThrow("timeSpentAt and timeSpentSummary require timeSpent");
});
it("should update work item with null parentId to unlink", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "100",
parentId: null,
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
hierarchyWidget: { parentId: null },
}),
})
);
});
it("should update work item with childrenIds", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "100",
childrenIds: ["201", "202"],
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
hierarchyWidget: {
childrenIds: ["gid://gitlab/WorkItem/201", "gid://gitlab/WorkItem/202"],
},
}),
})
);
});
it("should update work item with null iterationId to unassign", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "100",
iterationId: null,
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
iterationWidget: { iterationId: null },
}),
})
);
});
it("should update work item with isFixed dates", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "100",
startDate: "2025-03-01",
dueDate: "2025-03-31",
isFixed: true,
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
startAndDueDateWidget: {
startDate: "2025-03-01",
dueDate: "2025-03-31",
isFixed: true,
},
}),
})
);
});
it("should update work item with null dates to clear", async () => {
mockClient.request.mockResolvedValueOnce({
workItemUpdate: { workItem: mockWorkItemResponse, errors: [] },
});
const tool = workitemsToolRegistry.get("manage_work_item");
await tool?.handler({
action: "update",
id: "100",
startDate: null,
dueDate: null,
});
expect(mockClient.request).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
startAndDueDateWidget: { startDate: null, dueDate: null },
}),
})
);
});
});
describe("Error Handling", () => {
it("should handle GraphQL client errors gracefully", async () => {
mockClient.request.mockRejectedValueOnce(new Error("Network error"));
const tool = workitemsToolRegistry.get("browse_work_items");
await expect(tool?.handler({ action: "list", namespace: "test-group" })).rejects.toThrow(
"Network error"
);
});
it("should handle schema validation errors for browse_work_items", async () => {
const tool = workitemsToolRegistry.get("browse_work_items");
// Missing required action
await expect(tool?.handler({})).rejects.toThrow();
// Invalid action
await expect(
tool?.handler({ action: "invalid", namespace: "test-group" })
).rejects.toThrow();
// Missing namespace for list
await expect(tool?.handler({ action: "list" })).rejects.toThrow();
// Missing id for get
await expect(tool?.handler({ action: "get" })).rejects.toThrow();
});
it("should handle schema validation errors for manage_work_item", async () => {
const tool = workitemsToolRegistry.get("manage_work_item");
// Missing required action
await expect(tool?.handler({})).rejects.toThrow();
// Invalid action
await expect(tool?.handler({ action: "invalid", id: "123" })).rejects.toThrow();
// Missing namespace for create
await expect(
tool?.handler({ action: "create", title: "Test", workItemType: "EPIC" })
).rejects.toThrow();
// Missing id for update
await expect(tool?.handler({ action: "update", title: "Updated" })).rejects.toThrow();
// Missing id for delete
await expect(tool?.handler({ action: "delete" })).rejects.toThrow();
});
});
});
});