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) => { // 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; 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 | undefined { const raw = query?.filters; if (typeof raw !== "string") return undefined; const filters = JSON.parse(raw) as Array>; 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, calls: Array<{ path: string; query?: Record }> = [] ) { return { get: async (path: string, query?: Record) => { 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 }> = []; 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 }> = []; 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) => { 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"); }); });