The broad candidate leg was replaced with an empty array in `matchMode: "exact"`, so candidates came only from the server-side `subject ~ query` filter and any work package whose match lived in a description or a comment was unreachable. That inverts the parameter's own guidance: exact mode is advertised for literal strings known verbatim — error codes, ticket refs, commit SHAs — which are exactly the strings that live in descriptions and comments rather than subjects. Spec section 3.3 mandates two parallel requests with no exact-mode carve-out. Both legs now also page the candidate window in batches of 100 up to the unchanged 250 cap, the way meeting search already does. Every tool shape in this repo caps `pageSize` at 100 and OpenProject enforces a server-side maximum, so a single `pageSize: 250` request could silently return fewer candidates than the code claimed, with `response.total` ignored so nothing detected it. Also corrects `offset` on the search page from an item index to the 1-based page number its schema documents, and drops the duplicate `items` array from `WorkPackageSearchPage` (kept on `PaginatedResult` and the non-search list paths). The existing `workPackageFixtureClient` returns every element for any collection request and so cannot see a subject filter at all, which is why this was invisible to the suite. New tests use a fixture that honours the filter server-side; the old fixture is untouched. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
1227 lines
42 KiB
TypeScript
1227 lines
42 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.elements.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);
|
|
});
|
|
});
|
|
|
|
|
|
/**
|
|
* These fixtures deliberately HONOUR the server-side `subject ~` filter.
|
|
* `workPackageFixtureClient` above returns every element for any collection
|
|
* request, which is why the exact-mode candidate starvation bug was invisible
|
|
* to the suite: a subject-filtered leg that returns the whole corpus looks
|
|
* identical to a broad leg.
|
|
*/
|
|
describe("Work Package Search Candidate Union", () => {
|
|
interface FixtureElement {
|
|
_type: string;
|
|
id: number;
|
|
subject: string;
|
|
_links: Record<string, unknown>;
|
|
updatedAt: string;
|
|
}
|
|
|
|
function makeElement(id: number, subject: string, updatedAt: string): FixtureElement {
|
|
return {
|
|
_type: "WorkPackage",
|
|
id,
|
|
subject,
|
|
_links: {
|
|
type: { title: "Bug" },
|
|
status: { title: "New" },
|
|
project: { href: "/api/v3/projects/1", title: "Demo project" },
|
|
},
|
|
updatedAt,
|
|
};
|
|
}
|
|
|
|
/** Reads the `subject ~` value out of the serialized filter payload. */
|
|
function subjectFilterOf(query?: Record<string, unknown>): string | undefined {
|
|
const raw = query?.filters;
|
|
if (typeof raw !== "string") return undefined;
|
|
const filters = JSON.parse(raw) as Array<Record<string, { values?: string[] }>>;
|
|
for (const filter of filters) {
|
|
const subject = filter.subject;
|
|
if (subject) return subject.values?.[0];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* A fixture client that applies the subject filter server-side, the way a
|
|
* real OpenProject instance does.
|
|
*/
|
|
function subjectAwareClient(
|
|
elements: FixtureElement[],
|
|
deep: Record<number, { description?: string; comment?: string }>,
|
|
calls: Array<{ path: string; query?: Record<string, unknown> }> = []
|
|
) {
|
|
return {
|
|
get: async (path: string, query?: Record<string, unknown>) => {
|
|
calls.push({ path, query });
|
|
|
|
if (path.startsWith("work_packages/") || path.startsWith("/api/v3/work_packages/")) {
|
|
const id = Number(path.match(/work_packages\/(\d+)/)?.[1] ?? NaN);
|
|
if (path.includes("activities")) {
|
|
const comment = deep[id]?.comment;
|
|
return {
|
|
_type: "Collection",
|
|
_embedded: {
|
|
elements: comment
|
|
? [
|
|
{
|
|
id: id * 10,
|
|
version: 1,
|
|
createdAt: "2026-09-11T00:00:00Z",
|
|
comment: { raw: comment },
|
|
_links: { user: { href: "/api/v3/users/4", title: "Admin" } },
|
|
},
|
|
]
|
|
: [],
|
|
},
|
|
};
|
|
}
|
|
const match = elements.find((e) => e.id === id);
|
|
return { ...match, description: { raw: deep[id]?.description ?? "" } };
|
|
}
|
|
|
|
const subject = subjectFilterOf(query);
|
|
const matched =
|
|
subject === undefined
|
|
? elements
|
|
: elements.filter((e) =>
|
|
e.subject.toLowerCase().includes(subject.toLowerCase())
|
|
);
|
|
|
|
return {
|
|
_type: "Collection",
|
|
total: matched.length,
|
|
count: matched.length,
|
|
pageSize: 100,
|
|
offset: 1,
|
|
_embedded: { elements: matched },
|
|
};
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
}
|
|
|
|
const elements = [
|
|
makeElement(1, "Fix login redirect", "2026-09-10T00:00:00Z"),
|
|
makeElement(2, "Update dependencies", "2026-09-11T00:00:00Z"),
|
|
];
|
|
const deep = {
|
|
1: { description: "Session cookie is dropped" },
|
|
2: { comment: "Blocked by error ERR_TLS_EXPIRED on deploy" },
|
|
};
|
|
|
|
test("the subject-aware fixture really does filter (guards the tests below)", async () => {
|
|
const result = await searchWorkPackages(
|
|
"Update dependencies",
|
|
{ matchMode: "exact" },
|
|
subjectAwareClient(elements, deep)
|
|
);
|
|
expect(result.elements.map((e) => e.workPackage.id)).toEqual([2]);
|
|
});
|
|
|
|
test("exact mode finds a comment-only match the subject filter cannot return", async () => {
|
|
const result = await searchWorkPackages(
|
|
"ERR_TLS_EXPIRED",
|
|
{ matchMode: "exact" },
|
|
subjectAwareClient(elements, deep)
|
|
);
|
|
expect(result.elements).toHaveLength(1);
|
|
expect(result.elements[0]!.workPackage.id).toBe(2);
|
|
expect(result.elements[0]!.matchedFields).toContain("comments");
|
|
});
|
|
|
|
test("exact mode finds a description-only match the subject filter cannot return", async () => {
|
|
const result = await searchWorkPackages(
|
|
"Session cookie",
|
|
{ matchMode: "exact" },
|
|
subjectAwareClient(elements, deep)
|
|
);
|
|
expect(result.elements).toHaveLength(1);
|
|
expect(result.elements[0]!.workPackage.id).toBe(1);
|
|
expect(result.elements[0]!.matchedFields).toContain("description");
|
|
});
|
|
|
|
test("exact mode still issues both the precise and the broad candidate leg", async () => {
|
|
const calls: Array<{ path: string; query?: Record<string, unknown> }> = [];
|
|
await searchWorkPackages(
|
|
"login",
|
|
{ matchMode: "exact" },
|
|
subjectAwareClient(elements, deep, calls)
|
|
);
|
|
const collectionCalls = calls.filter((c) => c.path === "work_packages");
|
|
expect(collectionCalls.some((c) => subjectFilterOf(c.query) === "login")).toBe(true);
|
|
expect(collectionCalls.some((c) => subjectFilterOf(c.query) === undefined)).toBe(true);
|
|
});
|
|
|
|
test("never requests a page size above the documented 100 maximum", async () => {
|
|
const calls: Array<{ path: string; query?: Record<string, unknown> }> = [];
|
|
await searchWorkPackages("login", {}, subjectAwareClient(elements, deep, calls));
|
|
const pageSizes = calls
|
|
.filter((c) => c.path === "work_packages")
|
|
.map((c) => Number(c.query?.pageSize));
|
|
expect(pageSizes.length).toBeGreaterThan(0);
|
|
for (const pageSize of pageSizes) {
|
|
expect(pageSize).toBeLessThanOrEqual(100);
|
|
}
|
|
});
|
|
|
|
test("pages the candidate window until the server-reported total is collected", async () => {
|
|
const requestedOffsets: number[] = [];
|
|
// 150 candidates behind a server that hands back 100 per page: a single
|
|
// pageSize=250 request would have been capped at 100 by a real instance.
|
|
const corpus = Array.from({ length: 150 }, (_, i) =>
|
|
makeElement(i + 1, `Candidate ${i + 1}`, "2026-09-10T00:00:00Z")
|
|
);
|
|
|
|
const client = {
|
|
get: async (path: string, query?: Record<string, unknown>) => {
|
|
if (path.startsWith("work_packages/") || path.startsWith("/api/v3/work_packages/")) {
|
|
const id = Number(path.match(/work_packages\/(\d+)/)?.[1] ?? NaN);
|
|
if (path.includes("activities")) {
|
|
return { _type: "Collection", _embedded: { elements: [] } };
|
|
}
|
|
return { ...corpus.find((e) => e.id === id), description: { raw: "" } };
|
|
}
|
|
const pageSize = Number(query?.pageSize ?? 20);
|
|
expect(pageSize).toBeLessThanOrEqual(100);
|
|
const offset = Number(query?.offset ?? 1);
|
|
if (subjectFilterOf(query) === undefined) {
|
|
requestedOffsets.push(offset);
|
|
}
|
|
const start = (offset - 1) * pageSize;
|
|
const page = corpus.slice(start, start + pageSize);
|
|
return {
|
|
_type: "Collection",
|
|
total: corpus.length,
|
|
count: page.length,
|
|
pageSize,
|
|
offset,
|
|
_embedded: { elements: page },
|
|
};
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const result = await searchWorkPackages("Candidate 137", {}, client);
|
|
expect(requestedOffsets).toEqual([1, 2]);
|
|
// Candidate 137 lives on the second page; without paging it is unreachable.
|
|
expect(result.elements[0]!.workPackage.id).toBe(137);
|
|
});
|
|
});
|
|
|
|
describe("Work Package Search Response Shape", () => {
|
|
function pagedFixtureClient(count: number) {
|
|
const elements = Array.from({ length: count }, (_, i) => ({
|
|
_type: "WorkPackage",
|
|
id: i + 1,
|
|
subject: `Release checklist item ${i + 1}`,
|
|
_links: {
|
|
type: { title: "Task" },
|
|
status: { title: "New" },
|
|
project: { href: "/api/v3/projects/1", title: "Demo project" },
|
|
},
|
|
updatedAt: "2026-09-10T00:00:00Z",
|
|
}));
|
|
|
|
return {
|
|
get: async (path: string) => {
|
|
if (path.startsWith("work_packages/") || path.startsWith("/api/v3/work_packages/")) {
|
|
const id = Number(path.match(/work_packages\/(\d+)/)?.[1] ?? NaN);
|
|
if (path.includes("activities")) {
|
|
return { _type: "Collection", _embedded: { elements: [] } };
|
|
}
|
|
return { ...elements.find((e) => e.id === id), description: { raw: "" } };
|
|
}
|
|
return {
|
|
_type: "Collection",
|
|
total: elements.length,
|
|
count: elements.length,
|
|
pageSize: 100,
|
|
offset: 1,
|
|
_embedded: { elements },
|
|
};
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
}
|
|
|
|
test("offset is a page number: page 2 does not overlap page 1", async () => {
|
|
const client = pagedFixtureClient(12);
|
|
const page1 = await searchWorkPackages(
|
|
"Release checklist item",
|
|
{ offset: 1, pageSize: 5 },
|
|
client
|
|
);
|
|
const page2 = await searchWorkPackages(
|
|
"Release checklist item",
|
|
{ offset: 2, pageSize: 5 },
|
|
client
|
|
);
|
|
|
|
const whole = await searchWorkPackages(
|
|
"Release checklist item",
|
|
{ offset: 1, pageSize: 100 },
|
|
client
|
|
);
|
|
const allIds = whole.elements.map((e) => e.workPackage.id);
|
|
const ids1 = page1.elements.map((e) => e.workPackage.id);
|
|
const ids2 = page2.elements.map((e) => e.workPackage.id);
|
|
|
|
expect(allIds).toHaveLength(12);
|
|
expect(ids1).toEqual(allIds.slice(0, 5));
|
|
// The bug returned allIds.slice(1, 6) here — four of five items repeated.
|
|
expect(ids2).toEqual(allIds.slice(5, 10));
|
|
expect(ids1.filter((id) => ids2.includes(id))).toEqual([]);
|
|
});
|
|
|
|
test("does not ship the result array twice", async () => {
|
|
const result = await searchWorkPackages(
|
|
"Release checklist item",
|
|
{},
|
|
pagedFixtureClient(3)
|
|
);
|
|
expect(result.elements).toHaveLength(3);
|
|
expect(Object.keys(result)).not.toContain("items");
|
|
expect(JSON.parse(JSON.stringify(result))).not.toHaveProperty("items");
|
|
});
|
|
});
|