From b1348dbea8cfa94efadc41aa49cb1e26540dc0a3 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 5 Sep 2026 18:17:24 +0200 Subject: [PATCH] feat(services): implement work packages domain service --- src/services/work-packages.ts | 88 ++++++++++++++++++++++++ tests/services.test.ts | 124 ++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 src/services/work-packages.ts diff --git a/src/services/work-packages.ts b/src/services/work-packages.ts new file mode 100644 index 0000000..67f6203 --- /dev/null +++ b/src/services/work-packages.ts @@ -0,0 +1,88 @@ +/** + * Work Packages Domain Service. + */ + +import type { OpenProjectClient } from "../client/api-client.ts"; +import { buildWorkPackageFilters } from "../client/filter-builder.ts"; +import { + normalizeWorkPackage, + normalizeWorkPackageSummary, + unpackCollection, +} from "../client/hal-parser.ts"; +import type { + HalCollection, + HalResource, + PaginatedResult, + WorkPackageDetail, + WorkPackageFilterParams, + WorkPackageSummary, +} from "../client/types.ts"; +import { resolveClient } from "./helper.ts"; + +export interface ListWorkPackagesParams extends WorkPackageFilterParams { + pageSize?: number; + offset?: number; + sortBy?: string; +} + +export interface SearchWorkPackagesOptions { + projectId?: number | string; + pageSize?: number; + offset?: number; +} + +/** + * Lists work packages matching filters and pagination options. + */ +export async function listWorkPackages( + params?: ListWorkPackagesParams, + client?: OpenProjectClient +): Promise> { + const opClient = resolveClient(client); + const query: Record = {}; + + if (params?.pageSize !== undefined) query.pageSize = params.pageSize; + if (params?.offset !== undefined) query.offset = params.offset; + if (params?.sortBy !== undefined) query.sortBy = params.sortBy; + + if (params) { + const filters = buildWorkPackageFilters(params); + if (filters.length > 0) { + query.filters = JSON.stringify(filters); + } + } + + const response = await opClient.get>("work_packages", query); + return unpackCollection(response, normalizeWorkPackageSummary); +} + +/** + * Retrieves details for a specific work package by its numeric ID. + */ +export async function getWorkPackage( + id: number, + client?: OpenProjectClient +): Promise { + const opClient = resolveClient(client); + const response = await opClient.get(`work_packages/${id}`); + return normalizeWorkPackage(response); +} + +/** + * Convenience helper to search work packages by subject text, optionally within a project. + */ +export async function searchWorkPackages( + query: string, + options?: SearchWorkPackagesOptions, + client?: OpenProjectClient +): Promise> { + return listWorkPackages( + { + subject: query, + projectId: options?.projectId, + pageSize: options?.pageSize, + offset: options?.offset, + }, + client + ); +} diff --git a/tests/services.test.ts b/tests/services.test.ts index 085363c..bfec654 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import { resolveClient } 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 { OpenProjectClient } from "../src/client/api-client.ts"; import { runWithContext, type RequestContext } from "../src/context.ts"; @@ -127,3 +132,122 @@ describe("Projects Service", () => { }); }); +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 () => { + 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: [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); + expect(requestedUrl).toContain("filters="); + // Filter must include substring operator for subject + const decodedUrl = decodeURIComponent(requestedUrl.replace(/\+/g, " ")); + expect(decodedUrl).toContain('"subject"'); + expect(decodedUrl).toContain("MCP Server"); + }); +}); + +