The union candidate fetch wrapped both listWorkPackages() legs in an
unconditional .catch(() => ({ items: [] })), swallowing 401/403/429 and
OpenProjectAuthenticationError along with genuinely transient failures. An
expired token would silently degrade to "0 candidates", and the LLM would
report work packages as not found rather than surfacing the auth failure.
Replace the inline catches with a shared listCandidates() helper that uses
isFatalSearchError (already used by searchPipeline) to rethrow fatal errors
and only swallow non-fatal ones, preserving the property that a bad response
on one leg doesn't kill the other leg's candidates.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
944 lines
32 KiB
TypeScript
944 lines
32 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { resolveClient, resolveProjectId } from "../src/services/helper.ts";
|
|
import { listProjects, getProject, getProjectSchema } from "../src/services/projects.ts";
|
|
import {
|
|
listWorkPackages,
|
|
getWorkPackage,
|
|
searchWorkPackages,
|
|
} from "../src/services/work-packages.ts";
|
|
import { listQueries, getQuery, getQueryResults } from "../src/services/queries.ts";
|
|
import {
|
|
listStatuses,
|
|
listTypes,
|
|
listPriorities,
|
|
listUsers,
|
|
} from "../src/services/metadata.ts";
|
|
import * as domainServices from "../src/services/index.ts";
|
|
import {
|
|
OpenProjectClient,
|
|
OpenProjectAuthenticationError,
|
|
OpenProjectError,
|
|
} from "../src/client/api-client.ts";
|
|
import { runWithContext, type RequestContext } from "../src/context.ts";
|
|
|
|
describe("Domain Services Helper", () => {
|
|
test("resolveClient returns explicit client if provided", () => {
|
|
const customClient = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "custom-key",
|
|
});
|
|
const result = resolveClient(customClient);
|
|
expect(result).toBe(customClient);
|
|
});
|
|
|
|
test("resolveClient falls back to getRequestContext().client when omitted", () => {
|
|
const ambientClient = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "ambient-key",
|
|
});
|
|
const context: RequestContext = {
|
|
client: ambientClient,
|
|
isReadOnly: false,
|
|
};
|
|
|
|
runWithContext(context, () => {
|
|
const result = resolveClient();
|
|
expect(result).toBe(ambientClient);
|
|
});
|
|
});
|
|
|
|
test("resolveClient throws error when called without client and outside context", () => {
|
|
expect(() => resolveClient()).toThrow("No active RequestContext found");
|
|
});
|
|
|
|
test("resolveProjectId returns numeric ID directly if number or numeric string", async () => {
|
|
expect(await resolveProjectId(4)).toBe(4);
|
|
expect(await resolveProjectId("42")).toBe(42);
|
|
});
|
|
|
|
test("resolveProjectId fetches project ID when string identifier provided", async () => {
|
|
const mockFetch = async () => {
|
|
return new Response(JSON.stringify({ id: 99, identifier: "custom-slug" }), {
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
const id = await resolveProjectId("custom-slug", client);
|
|
expect(id).toBe(99);
|
|
});
|
|
});
|
|
|
|
describe("Projects Service", () => {
|
|
const sampleProjectHal = {
|
|
_type: "Project",
|
|
id: 4,
|
|
identifier: "mcp-test-project",
|
|
name: "MCP Test Project",
|
|
active: true,
|
|
public: true,
|
|
description: { format: "markdown", raw: "Dedicated test project" },
|
|
_links: {
|
|
self: { href: "/api/v3/projects/4", title: "MCP Test Project" },
|
|
parent: { href: "/api/v3/projects/1", title: "Parent Project" },
|
|
},
|
|
};
|
|
|
|
test("listProjects sends correct parameters and returns paginated ProjectSummary items", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
total: 1,
|
|
count: 1,
|
|
pageSize: 10,
|
|
offset: 1,
|
|
_embedded: {
|
|
elements: [sampleProjectHal],
|
|
},
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const result = await listProjects({ pageSize: 10, offset: 1, sortBy: '[["name","asc"]]' }, client);
|
|
|
|
expect(requestedUrl).toContain("/api/v3/projects");
|
|
expect(requestedUrl).toContain("pageSize=10");
|
|
expect(requestedUrl).toContain("offset=1");
|
|
expect(requestedUrl).toContain("sortBy=");
|
|
expect(result.total).toBe(1);
|
|
expect(result.items[0]!.id).toBe(4);
|
|
expect(result.items[0]!.identifier).toBe("mcp-test-project");
|
|
expect(result.items[0]!.name).toBe("MCP Test Project");
|
|
});
|
|
|
|
test("getProject retrieves project detail by id or identifier", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(JSON.stringify(sampleProjectHal), {
|
|
headers: { "Content-Type": "application/hal+json" },
|
|
});
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const result = await getProject("mcp-test-project", client);
|
|
expect(requestedUrl).toContain("/api/v3/projects/mcp-test-project");
|
|
expect(result.id).toBe(4);
|
|
expect(result.parentId).toBe(1);
|
|
expect(result.parentName).toBe("Parent Project");
|
|
});
|
|
|
|
test("getProject encodes URI component in id or identifier parameter", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(JSON.stringify(sampleProjectHal), {
|
|
headers: { "Content-Type": "application/hal+json" },
|
|
});
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
await getProject("special slug/test", client);
|
|
expect(requestedUrl).toContain("/api/v3/projects/special%20slug%2Ftest");
|
|
});
|
|
|
|
test("listProjects serializes filters parameter to JSON query string", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
total: 1,
|
|
count: 1,
|
|
pageSize: 10,
|
|
offset: 1,
|
|
_embedded: { elements: [sampleProjectHal] },
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const filters = [{ active: { operator: "=", values: ["t"] } }];
|
|
await listProjects({ filters }, client);
|
|
|
|
expect(requestedUrl).toContain("/api/v3/projects");
|
|
expect(requestedUrl).toContain("filters=");
|
|
const decodedUrl = decodeURIComponent(requestedUrl);
|
|
expect(decodedUrl).toContain(JSON.stringify(filters));
|
|
});
|
|
|
|
test("listProjects resolves ambient client from RequestContext when client is omitted", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
total: 1,
|
|
count: 1,
|
|
pageSize: 10,
|
|
offset: 1,
|
|
_embedded: { elements: [sampleProjectHal] },
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const ambientClient = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "ambient-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const context: RequestContext = {
|
|
client: ambientClient,
|
|
isReadOnly: false,
|
|
};
|
|
|
|
const result = await runWithContext(context, async () => {
|
|
return listProjects();
|
|
});
|
|
|
|
expect(requestedUrl).toContain("/api/v3/projects");
|
|
expect(result.total).toBe(1);
|
|
});
|
|
|
|
test("getProjectSchema retrieves project schema definition", async () => {
|
|
const mockFetch = async () => {
|
|
return new Response(JSON.stringify({ _type: "Schema", name: { type: "String" } }), {
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const result = await getProjectSchema(client);
|
|
expect(result._type).toBe("Schema");
|
|
});
|
|
});
|
|
|
|
describe("Work Packages Service", () => {
|
|
const sampleWpHal = {
|
|
_type: "WorkPackage",
|
|
id: 38,
|
|
subject: "Implement MCP Server Core Protocol",
|
|
description: { format: "markdown", raw: "Build stdio transport" },
|
|
startDate: "2026-09-01",
|
|
dueDate: "2026-09-10",
|
|
lockVersion: 3,
|
|
_links: {
|
|
self: { href: "/api/v3/work_packages/38", title: "Implement MCP Server Core Protocol" },
|
|
project: { href: "/api/v3/projects/4", title: "MCP Test Project" },
|
|
type: { href: "/api/v3/types/1", title: "Task" },
|
|
status: { href: "/api/v3/statuses/2", title: "In progress" },
|
|
priority: { href: "/api/v3/priorities/8", title: "High" },
|
|
author: { href: "/api/v3/users/1", title: "Admin User" },
|
|
assignee: { href: "/api/v3/users/1", title: "Admin User" },
|
|
parent: { href: "/api/v3/work_packages/30", title: "Epic Parent" },
|
|
children: [
|
|
{ href: "/api/v3/work_packages/39", title: "Subtask 1" },
|
|
{ href: "/api/v3/work_packages/40", title: "Subtask 2" },
|
|
],
|
|
},
|
|
};
|
|
|
|
test("listWorkPackages applies filter parameters and normalizes collection", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
total: 1,
|
|
count: 1,
|
|
pageSize: 25,
|
|
offset: 1,
|
|
_embedded: { elements: [sampleWpHal] },
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const result = await listWorkPackages(
|
|
{ projectId: 4, status: "open", typeId: 1, pageSize: 25 },
|
|
client
|
|
);
|
|
|
|
expect(requestedUrl).toContain("/api/v3/work_packages");
|
|
expect(requestedUrl).toContain("filters=");
|
|
expect(requestedUrl).toContain("pageSize=25");
|
|
expect(result.total).toBe(1);
|
|
expect(result.items[0]!.id).toBe(38);
|
|
expect(result.items[0]!.subject).toBe("Implement MCP Server Core Protocol");
|
|
expect(result.items[0]!.status).toBe("In progress");
|
|
expect(result.items[0]!.type).toBe("Task");
|
|
});
|
|
|
|
test("getWorkPackage returns full details with parent and children", async () => {
|
|
const mockFetch = async () => {
|
|
return new Response(JSON.stringify(sampleWpHal), {
|
|
headers: { "Content-Type": "application/hal+json" },
|
|
});
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const wp = await getWorkPackage(38, client);
|
|
expect(wp.id).toBe(38);
|
|
expect(wp.subject).toBe("Implement MCP Server Core Protocol");
|
|
expect(wp.parent?.id).toBe(30);
|
|
expect(wp.parent?.subject).toBe("Epic Parent");
|
|
expect(wp.children?.length).toBe(2);
|
|
expect(wp.children?.[0]?.id).toBe(39);
|
|
expect(wp.lockVersion).toBe(3);
|
|
});
|
|
|
|
test("searchWorkPackages sets subject query filter", async () => {
|
|
// The union strategy issues several requests in parallel (the precise
|
|
// subject filter, a broad recency window, and per-candidate enrichment
|
|
// calls), so every requested URL is captured rather than just the last.
|
|
const requestedUrls: string[] = [];
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrls.push(String(input));
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
total: 1,
|
|
count: 1,
|
|
pageSize: 10,
|
|
offset: 1,
|
|
_embedded: { elements: [sampleWpHal] },
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
await searchWorkPackages("MCP Server", { projectId: 4 }, client);
|
|
|
|
// Filter must include substring operator for subject on the precise request.
|
|
const decodedUrls = requestedUrls.map((url) => decodeURIComponent(url.replace(/\+/g, " ")));
|
|
const subjectRequestUrl = decodedUrls.find((url) => url.includes('"subject"'));
|
|
expect(subjectRequestUrl).toBeDefined();
|
|
expect(subjectRequestUrl).toContain("MCP Server");
|
|
});
|
|
});
|
|
|
|
describe("Queries Service", () => {
|
|
const sampleQueryHal = {
|
|
_type: "Query",
|
|
id: 30,
|
|
name: "MCP Active Tasks",
|
|
public: true,
|
|
starred: false,
|
|
_links: {
|
|
self: { href: "/api/v3/queries/30", title: "MCP Active Tasks" },
|
|
project: { href: "/api/v3/projects/4", title: "MCP Test Project" },
|
|
results: { href: "/api/v3/queries/30/results" },
|
|
columns: [
|
|
{ href: "/api/v3/queries/columns/id", title: "ID" },
|
|
{ href: "/api/v3/queries/columns/subject", title: "Subject" },
|
|
{ href: "/api/v3/queries/columns/status", title: "Status" },
|
|
],
|
|
sortBy: [
|
|
{ href: "/api/v3/queries/sort_bys/id-asc", title: "ID ascending" },
|
|
],
|
|
},
|
|
filters: [
|
|
{
|
|
_links: {
|
|
operator: { href: "/api/v3/queries/operators/=", title: "is" },
|
|
filter: { href: "/api/v3/queries/filters/status", title: "Status" },
|
|
},
|
|
name: "status",
|
|
values: ["open"],
|
|
},
|
|
],
|
|
};
|
|
|
|
test("listQueries filters by project when provided", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
total: 1,
|
|
count: 1,
|
|
pageSize: 20,
|
|
offset: 1,
|
|
_embedded: { elements: [sampleQueryHal] },
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const result = await listQueries({ projectId: 4, pageSize: 20 }, client);
|
|
expect(requestedUrl).toContain("/api/v3/queries");
|
|
expect(requestedUrl).toContain("pageSize=20");
|
|
const decodedUrl = decodeURIComponent(requestedUrl);
|
|
expect(decodedUrl).toContain('"project"');
|
|
expect(result.items[0]!.id).toBe(30);
|
|
expect(result.items[0]!.name).toBe("MCP Active Tasks");
|
|
expect(result.items[0]!.projectId).toBe(4);
|
|
});
|
|
|
|
test("getQuery returns normalized details with columns and filters", async () => {
|
|
const mockFetch = async () => {
|
|
return new Response(JSON.stringify(sampleQueryHal), {
|
|
headers: { "Content-Type": "application/hal+json" },
|
|
});
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const query = await getQuery(30, client);
|
|
expect(query.id).toBe(30);
|
|
expect(query.name).toBe("MCP Active Tasks");
|
|
expect(query.columns).toContain("ID");
|
|
expect(query.columns).toContain("Subject");
|
|
expect(query.sortBy?.[0]?.attribute).toBe("id");
|
|
expect(query.sortBy?.[0]?.direction).toBe("asc");
|
|
expect(query.filters?.[0]?.field).toBe("Status");
|
|
expect(query.resultsHref).toBe("/api/v3/queries/30/results");
|
|
});
|
|
|
|
test("getQueryResults fetches work packages produced by saved query", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
total: 1,
|
|
count: 1,
|
|
pageSize: 10,
|
|
offset: 1,
|
|
_embedded: {
|
|
elements: [
|
|
{
|
|
_type: "WorkPackage",
|
|
id: 38,
|
|
subject: "Implement MCP Server Core Protocol",
|
|
_links: {
|
|
self: { href: "/api/v3/work_packages/38" },
|
|
type: { title: "Task" },
|
|
status: { title: "In progress" },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const result = await getQueryResults(30, { pageSize: 10 }, client);
|
|
expect(requestedUrl).toContain("/api/v3/queries/30/results");
|
|
expect(requestedUrl).toContain("pageSize=10");
|
|
expect(result.items[0]!.id).toBe(38);
|
|
expect(result.items[0]!.subject).toBe("Implement MCP Server Core Protocol");
|
|
});
|
|
});
|
|
|
|
describe("Metadata Service", () => {
|
|
test("listStatuses retrieves and unpacks status items", async () => {
|
|
const mockFetch = async () => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
_embedded: {
|
|
elements: [
|
|
{ id: 1, name: "New", isClosed: false, isDefault: true },
|
|
{ id: 2, name: "Closed", isClosed: true, isDefault: false },
|
|
],
|
|
},
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const statuses = await listStatuses(client);
|
|
expect(statuses.length).toBe(2);
|
|
expect(statuses[0]!.name).toBe("New");
|
|
expect(statuses[0]!.isClosed).toBe(false);
|
|
expect(statuses[1]!.isClosed).toBe(true);
|
|
});
|
|
|
|
test("listTypes scopes to project when projectId is provided", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
_embedded: {
|
|
elements: [{ id: 1, name: "Task", isMilestone: false }],
|
|
},
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const types = await listTypes({ projectId: 4 }, client);
|
|
expect(requestedUrl).toContain("/api/v3/projects/4/types");
|
|
expect(types[0]!.name).toBe("Task");
|
|
});
|
|
|
|
test("listTypes fetches global types when projectId is omitted", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
_embedded: {
|
|
elements: [{ id: 1, name: "Task", isMilestone: false }],
|
|
},
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const types = await listTypes(undefined, client);
|
|
expect(requestedUrl).toContain("/api/v3/types");
|
|
expect(requestedUrl).not.toContain("/projects/");
|
|
expect(types[0]!.name).toBe("Task");
|
|
});
|
|
|
|
test("listPriorities retrieves priority items", async () => {
|
|
const mockFetch = async () => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
_embedded: {
|
|
elements: [{ id: 8, name: "High", isActive: true }],
|
|
},
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const priorities = await listPriorities(client);
|
|
expect(priorities[0]!.name).toBe("High");
|
|
});
|
|
|
|
test("listUsers returns paginated UserItem list", async () => {
|
|
let requestedUrl = "";
|
|
const mockFetch = async (input: string | URL | Request) => {
|
|
requestedUrl = String(input);
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
total: 1,
|
|
count: 1,
|
|
pageSize: 25,
|
|
offset: 1,
|
|
_embedded: {
|
|
elements: [{ id: 1, name: "Admin User", login: "admin", admin: true }],
|
|
},
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const client = new OpenProjectClient({
|
|
baseUrl: "http://example.com",
|
|
apiKey: "test-key",
|
|
fetchFn: mockFetch,
|
|
});
|
|
|
|
const users = await listUsers({ pageSize: 25, offset: 1 }, client);
|
|
expect(requestedUrl).toContain("/api/v3/users");
|
|
expect(users.items[0]!.login).toBe("admin");
|
|
expect(users.items[0]!.admin).toBe(true);
|
|
});
|
|
|
|
test("src/services/index.ts exports all services properly", () => {
|
|
expect(typeof domainServices.listProjects).toBe("function");
|
|
expect(typeof domainServices.getProject).toBe("function");
|
|
expect(typeof domainServices.getProjectSchema).toBe("function");
|
|
expect(typeof domainServices.listWorkPackages).toBe("function");
|
|
expect(typeof domainServices.getWorkPackage).toBe("function");
|
|
expect(typeof domainServices.listWorkPackageActivities).toBe("function");
|
|
expect(typeof domainServices.listQueries).toBe("function");
|
|
expect(typeof domainServices.getQuery).toBe("function");
|
|
expect(typeof domainServices.listStatuses).toBe("function");
|
|
expect(typeof domainServices.listTypes).toBe("function");
|
|
expect(typeof domainServices.listPriorities).toBe("function");
|
|
expect(typeof domainServices.listUsers).toBe("function");
|
|
expect(typeof domainServices.listMeetings).toBe("function");
|
|
expect(typeof domainServices.getMeeting).toBe("function");
|
|
expect(typeof domainServices.searchMeetings).toBe("function");
|
|
expect(typeof domainServices.getWikiPage).toBe("function");
|
|
expect(typeof domainServices.searchWikiPages).toBe("function");
|
|
expect(typeof domainServices.listWikiPageLinks).toBe("function");
|
|
});
|
|
});
|
|
|
|
describe("Live Container Integration (Domain Services)", () => {
|
|
const liveBaseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080";
|
|
const liveApiKey = process.env.OPENPROJECT_API_KEY;
|
|
|
|
const runLiveTests = liveApiKey ? test : test.skip;
|
|
|
|
runLiveTests("live: projects service operations", async () => {
|
|
const client = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey! });
|
|
|
|
const projects = await domainServices.listProjects({ pageSize: 5 }, client);
|
|
expect(projects.items.length).toBeGreaterThan(0);
|
|
|
|
const testProject = await domainServices.getProject("mcp-test-project", client);
|
|
expect(testProject.identifier).toBe("mcp-test-project");
|
|
expect(testProject.id).toBeGreaterThan(0);
|
|
});
|
|
|
|
runLiveTests("live: work packages service operations", async () => {
|
|
const client = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey! });
|
|
|
|
const wps = await domainServices.listWorkPackages({ projectId: "mcp-test-project" }, client);
|
|
expect(wps.items.length).toBeGreaterThan(0);
|
|
|
|
const firstWp = await domainServices.getWorkPackage(wps.items[0]!.id, client);
|
|
expect(firstWp.id).toBe(wps.items[0]!.id);
|
|
expect(firstWp.subject).toBeDefined();
|
|
|
|
const searchRes = await domainServices.searchWorkPackages("MCP", { projectId: "mcp-test-project" }, client);
|
|
expect(searchRes.items.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
runLiveTests("live: queries service operations", async () => {
|
|
const client = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey! });
|
|
|
|
const queries = await domainServices.listQueries({ projectId: "mcp-test-project" }, client);
|
|
expect(queries.items.length).toBeGreaterThan(0);
|
|
|
|
const activeQuery = queries.items.find((q) => q.name.includes("MCP Active Tasks"));
|
|
expect(activeQuery).toBeDefined();
|
|
if (activeQuery) {
|
|
const detail = await domainServices.getQuery(activeQuery.id, client);
|
|
expect(detail.id).toBe(activeQuery.id);
|
|
|
|
const results = await domainServices.getQueryResults(activeQuery.id, {}, client);
|
|
expect(results.items.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
runLiveTests("live: metadata service operations", async () => {
|
|
const client = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey! });
|
|
|
|
const statuses = await domainServices.listStatuses(client);
|
|
expect(statuses.length).toBeGreaterThan(0);
|
|
|
|
const types = await domainServices.listTypes({ projectId: "mcp-test-project" }, client);
|
|
expect(types.length).toBeGreaterThan(0);
|
|
|
|
const priorities = await domainServices.listPriorities(client);
|
|
expect(priorities.length).toBeGreaterThan(0);
|
|
|
|
const users = await domainServices.listUsers({ pageSize: 5 }, client);
|
|
expect(users.items.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
runLiveTests("live: meetings service operations", async () => {
|
|
const client = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey! });
|
|
|
|
// list meetings (should return meetings 2..5)
|
|
const meetings = await domainServices.listMeetings({ projectId: 1 }, client);
|
|
expect(meetings.elements.length).toBeGreaterThanOrEqual(4);
|
|
const meetingIds = meetings.elements.map((m) => m.id);
|
|
expect(meetingIds).toContain(2);
|
|
expect(meetingIds).toContain(3);
|
|
expect(meetingIds).toContain(4);
|
|
expect(meetingIds).toContain(5);
|
|
|
|
// get meeting (should return meeting with title "Weekly" and agenda items)
|
|
const meeting = await domainServices.getMeeting(2, { includeAgendaItems: true }, client);
|
|
expect(meeting.id).toBe(2);
|
|
expect(meeting.title).toBe("Weekly");
|
|
expect(meeting.agendaItems).toBeDefined();
|
|
expect(meeting.agendaItems!.length).toBeGreaterThan(0);
|
|
expect(meeting.agendaItems![0]!.title).toBeDefined();
|
|
|
|
// search meetings (should return results with matching items)
|
|
const searchRes = await domainServices.searchMeetings({ query: "Weekly" }, client);
|
|
expect(searchRes.elements.length).toBeGreaterThan(0);
|
|
const match = searchRes.elements.find((r) => r.meeting.title.includes("Weekly"));
|
|
expect(match).toBeDefined();
|
|
});
|
|
|
|
runLiveTests("live: wikis service operations", async () => {
|
|
const client = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey! });
|
|
|
|
// get wiki page (should return title "Wiki", project id 1)
|
|
const wiki = await domainServices.getWikiPage(1, client);
|
|
expect(wiki.id).toBe(1);
|
|
expect(wiki.title).toBe("Wiki");
|
|
expect(wiki.project.id).toBe(1);
|
|
|
|
// search wiki pages (should discover and return wiki page 1)
|
|
const searchRes = await domainServices.searchWikiPages({ query: "Wiki" }, client);
|
|
expect(searchRes.length).toBeGreaterThan(0);
|
|
const found = searchRes.find((w) => w.id === 1);
|
|
expect(found).toBeDefined();
|
|
expect(found!.title).toBe("Wiki");
|
|
expect(found!.project.id).toBe(1);
|
|
});
|
|
|
|
runLiveTests("live: activities service operations", async () => {
|
|
const client = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey! });
|
|
|
|
// list work package activities (should return activities for WP 38)
|
|
const activities = await domainServices.listWorkPackageActivities({ workPackageId: 38 }, client);
|
|
expect(activities.length).toBeGreaterThan(0);
|
|
expect(activities[0]!.id).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("Work Package Fuzzy Search", () => {
|
|
function workPackageFixtureClient(calls: string[] = []) {
|
|
const elements = [
|
|
{
|
|
_type: "WorkPackage",
|
|
id: 1,
|
|
subject: "Fix login redirect",
|
|
_links: {
|
|
type: { title: "Bug" },
|
|
status: { title: "New" },
|
|
project: { href: "/api/v3/projects/1", title: "Demo project" },
|
|
},
|
|
updatedAt: "2026-09-10T00:00:00Z",
|
|
},
|
|
{
|
|
_type: "WorkPackage",
|
|
id: 2,
|
|
subject: "Update dependencies",
|
|
_links: {
|
|
type: { title: "Task" },
|
|
status: { title: "New" },
|
|
project: { href: "/api/v3/projects/1", title: "Demo project" },
|
|
},
|
|
updatedAt: "2026-09-11T00:00:00Z",
|
|
},
|
|
];
|
|
|
|
return {
|
|
get: async (path: string, query?: Record<string, unknown>) => {
|
|
// Record the query alongside the path so assertions can inspect the
|
|
// filter payload (the real client encodes filters into the query
|
|
// string of the resolved URL rather than the bare path).
|
|
calls.push(query ? `${path}?${JSON.stringify(query)}` : path);
|
|
if (path.startsWith("work_packages/") || path.startsWith("/api/v3/work_packages/")) {
|
|
// Extract the id following "work_packages/" specifically, rather
|
|
// than concatenating every digit in the path: the activities path
|
|
// (`/api/v3/work_packages/{id}/activities`) contains a stray digit
|
|
// in "v3" that a blanket \D+ strip would fold into the id.
|
|
const id = Number(path.match(/work_packages\/(\d+)/)?.[1] ?? NaN);
|
|
if (path.includes("activities")) {
|
|
return {
|
|
_type: "Collection",
|
|
_embedded: {
|
|
elements:
|
|
id === 2
|
|
? [
|
|
{
|
|
id: 90,
|
|
version: 1,
|
|
createdAt: "2026-09-11T00:00:00Z",
|
|
comment: { raw: "Blocked by the expired TLS certificate" },
|
|
_links: { user: { href: "/api/v3/users/4", title: "Admin" } },
|
|
},
|
|
]
|
|
: [],
|
|
},
|
|
};
|
|
}
|
|
const match = elements.find((e) => e.id === id);
|
|
return { ...match, description: { raw: id === 1 ? "Session cookie is dropped" : "" } };
|
|
}
|
|
return {
|
|
_type: "Collection",
|
|
total: elements.length,
|
|
count: elements.length,
|
|
pageSize: 100,
|
|
offset: 1,
|
|
_embedded: { elements },
|
|
};
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
}
|
|
|
|
test("finds a work package despite a typo in the subject query", async () => {
|
|
const result = await searchWorkPackages("login redirct", {}, workPackageFixtureClient());
|
|
expect(result.elements[0]!.workPackage.id).toBe(1);
|
|
});
|
|
|
|
test("finds a work package by a comment only", async () => {
|
|
const result = await searchWorkPackages("TLS certificate", {}, workPackageFixtureClient());
|
|
expect(result.elements[0]!.workPackage.id).toBe(2);
|
|
expect(result.elements[0]!.matchedFields).toContain("comments");
|
|
});
|
|
|
|
test("finds a work package by description only", async () => {
|
|
const result = await searchWorkPackages("session cookie", {}, workPackageFixtureClient());
|
|
expect(result.elements[0]!.workPackage.id).toBe(1);
|
|
expect(result.elements[0]!.matchedFields).toContain("description");
|
|
});
|
|
|
|
test("still issues the precise server-side subject filter", async () => {
|
|
const calls: string[] = [];
|
|
await searchWorkPackages("login", {}, workPackageFixtureClient(calls));
|
|
expect(calls.some((path) => path.includes("subject"))).toBe(true);
|
|
});
|
|
|
|
test("exact mode finds nothing for a typo", async () => {
|
|
const result = await searchWorkPackages(
|
|
"login redirct",
|
|
{ matchMode: "exact" },
|
|
workPackageFixtureClient()
|
|
);
|
|
expect(result.elements).toHaveLength(0);
|
|
});
|
|
|
|
test("propagates an auth failure from candidate fetch instead of reporting no results", async () => {
|
|
const client = {
|
|
get: async () => {
|
|
throw new OpenProjectAuthenticationError();
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
await expect(searchWorkPackages("anything", {}, client)).rejects.toThrow(
|
|
OpenProjectAuthenticationError
|
|
);
|
|
});
|
|
|
|
test("a non-fatal failure on one candidate leg still returns results from the other", async () => {
|
|
let calls = 0;
|
|
const client = {
|
|
get: async (path: string) => {
|
|
calls++;
|
|
// Fail the precise (subject-filtered) leg non-fatally; the broad
|
|
// leg (no subject filter) should still supply candidates.
|
|
if (calls === 1) {
|
|
throw new OpenProjectError("boom", { statusCode: 500 });
|
|
}
|
|
return {
|
|
_type: "Collection",
|
|
total: 1,
|
|
count: 1,
|
|
pageSize: 100,
|
|
offset: 1,
|
|
_embedded: {
|
|
elements: [
|
|
{
|
|
_type: "WorkPackage",
|
|
id: 1,
|
|
subject: "Fix login redirect",
|
|
_links: {
|
|
type: { title: "Bug" },
|
|
status: { title: "New" },
|
|
project: { href: "/api/v3/projects/1", title: "Demo project" },
|
|
},
|
|
updatedAt: "2026-09-10T00:00:00Z",
|
|
},
|
|
],
|
|
},
|
|
};
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const result = await searchWorkPackages("login", {}, client);
|
|
expect(result.elements[0]!.workPackage.id).toBe(1);
|
|
});
|
|
});
|
|
|