# Domain Services Layer Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement the stateless Domain Services layer (`src/services/`) providing projects, work packages, queries, and metadata operations with HAL+JSON normalization and RequestContext client resolution. **Architecture:** Functional TypeScript modules in `src/services/` with an internal `resolveClient(client?: OpenProjectClient)` fallback to `getRequestContext().client`. Service functions invoke OpenProject REST API v3 endpoints via `OpenProjectClient`, format query parameters using `FilterBuilder` where applicable, and normalize responses via `hal-parser.ts`. **Tech Stack:** Bun runtime, TypeScript (strict mode), `bun:test` for testing, native `fetch`, OpenProject REST API v3. **Spec:** [docs/specs/2026-09-05-domain-services-design.md](../specs/2026-09-05-domain-services-design.md) ## Global Constraints - Target Bun runtime (>= 1.2 / 1.3), using `bun test` and `Bun.file`. - Zero global mutable state; clients must be resolved via `getRequestContext()` or explicit argument. - Never log, print, or leak API keys or authorization headers in errors or payloads. - Strict TypeScript (`noImplicitAny`, strict types, explicit interfaces). - Domain services must be stateless and free of CLI / MCP transport concerns. --- ### Task 1: Shared Service Helper (`src/services/helper.ts`) **Files:** - Create: `src/services/helper.ts` - Test: `tests/services.test.ts` **Interfaces:** - Consumes: `getRequestContext` from `src/context.ts`, `OpenProjectClient` from `src/client/api-client.ts` - Produces: `resolveClient(client?: OpenProjectClient): OpenProjectClient` - [x] **Step 1: Write the failing test for client resolution** In `tests/services.test.ts`: ```typescript import { describe, expect, test } from "bun:test"; import { resolveClient } from "../src/services/helper.ts"; import { OpenProjectClient } 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"); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/services.test.ts` Expected: FAIL with module not found `../src/services/helper.ts` - [x] **Step 3: Write minimal implementation** In `src/services/helper.ts`: ```typescript /** * Shared helper utilities for domain services. */ import { getRequestContext } from "../context.ts"; import type { OpenProjectClient } from "../client/api-client.ts"; /** * Resolves an OpenProjectClient instance. * Prefers an explicitly passed client; falls back to the ambient RequestContext. */ export function resolveClient(client?: OpenProjectClient): OpenProjectClient { return client ?? getRequestContext().client; } ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/services.test.ts` Expected: PASS (3 tests passed) - [x] **Step 5: Commit** ```bash git add src/services/helper.ts tests/services.test.ts git commit -m "feat(services): implement shared client resolution helper" ``` --- ### Task 2: Projects Service (`src/services/projects.ts`) **Files:** - Create: `src/services/projects.ts` - Modify: `tests/services.test.ts` **Interfaces:** - Consumes: - `resolveClient` from `src/services/helper.ts` - `OpenProjectClient` from `src/client/api-client.ts` - `unpackCollection`, `normalizeProjectSummary`, `normalizeProject` from `src/client/hal-parser.ts` - `ProjectSummary`, `ProjectDetail`, `PaginatedResult`, `FilterElement` from `src/client/types.ts` - Produces: - `listProjects(params?: ListProjectsParams, client?: OpenProjectClient): Promise>` - `getProject(idOrIdentifier: number | string, client?: OpenProjectClient): Promise` - `getProjectSchema(client?: OpenProjectClient): Promise>` - `ListProjectsParams` interface - [x] **Step 1: Write failing tests for projects service** Append to `tests/services.test.ts`: ```typescript import { listProjects, getProject, getProjectSchema } from "../src/services/projects.ts"; 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("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"); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/services.test.ts` Expected: FAIL with module not found `../src/services/projects.ts` - [x] **Step 3: Write minimal implementation** In `src/services/projects.ts`: ```typescript /** * Projects Domain Service. */ import type { OpenProjectClient } from "../client/api-client.ts"; import { normalizeProject, normalizeProjectSummary, unpackCollection, } from "../client/hal-parser.ts"; import type { FilterElement, HalCollection, HalResource, PaginatedResult, ProjectDetail, ProjectSummary, } from "../client/types.ts"; import { resolveClient } from "./helper.ts"; export interface ListProjectsParams { pageSize?: number; offset?: number; sortBy?: string; filters?: FilterElement[]; } /** * Lists projects accessible to the authenticated user. */ export async function listProjects( params?: ListProjectsParams, 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?.filters && params.filters.length > 0) { query.filters = JSON.stringify(params.filters); } const response = await opClient.get>("projects", query); return unpackCollection(response, normalizeProjectSummary); } /** * Retrieves a single project by its numeric ID or string identifier. */ export async function getProject( idOrIdentifier: number | string, client?: OpenProjectClient ): Promise { const opClient = resolveClient(client); const response = await opClient.get(`projects/${idOrIdentifier}`); return normalizeProject(response); } /** * Retrieves the schema definition for projects. */ export async function getProjectSchema( client?: OpenProjectClient ): Promise> { const opClient = resolveClient(client); return opClient.get>("projects/schema"); } ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/services.test.ts` Expected: PASS - [x] **Step 5: Commit** ```bash git add src/services/projects.ts tests/services.test.ts git commit -m "feat(services): implement projects domain service" ``` --- ### Task 3: Work Packages Service (`src/services/work-packages.ts`) **Files:** - Create: `src/services/work-packages.ts` - Modify: `tests/services.test.ts` **Interfaces:** - Consumes: - `resolveClient` from `src/services/helper.ts` - `OpenProjectClient` from `src/client/api-client.ts` - `buildWorkPackageFilters` from `src/client/filter-builder.ts` - `unpackCollection`, `normalizeWorkPackageSummary`, `normalizeWorkPackage` from `src/client/hal-parser.ts` - `WorkPackageSummary`, `WorkPackageDetail`, `WorkPackageFilterParams`, `PaginatedResult`, `HalCollection`, `HalResource` from `src/client/types.ts` - Produces: - `listWorkPackages(params?: ListWorkPackagesParams, client?: OpenProjectClient): Promise>` - `getWorkPackage(id: number, client?: OpenProjectClient): Promise` - `searchWorkPackages(query: string, options?: SearchWorkPackagesOptions, client?: OpenProjectClient): Promise>` - `ListWorkPackagesParams`, `SearchWorkPackagesOptions` interfaces - [x] **Step 1: Write failing tests for work packages service** Append to `tests/services.test.ts`: ```typescript import { listWorkPackages, getWorkPackage, searchWorkPackages, } from "../src/services/work-packages.ts"; 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); expect(decodedUrl).toContain('"subject"'); expect(decodedUrl).toContain("MCP Server"); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/services.test.ts` Expected: FAIL with module not found `../src/services/work-packages.ts` - [x] **Step 3: Write minimal implementation** In `src/services/work-packages.ts`: ```typescript /** * 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 ); } ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/services.test.ts` Expected: PASS - [x] **Step 5: Commit** ```bash git add src/services/work-packages.ts tests/services.test.ts git commit -m "feat(services): implement work packages domain service" ``` --- ### Task 4: Queries Service (`src/services/queries.ts`) **Files:** - Create: `src/services/queries.ts` - Modify: `tests/services.test.ts` **Interfaces:** - Consumes: - `resolveClient` from `src/services/helper.ts` - `OpenProjectClient` from `src/client/api-client.ts` - `unpackCollection`, `normalizeQuerySummary`, `normalizeQuery`, `normalizeWorkPackageSummary` from `src/client/hal-parser.ts` - `QuerySummary`, `QueryDetail`, `WorkPackageSummary`, `PaginatedResult`, `HalCollection`, `HalResource` from `src/client/types.ts` - Produces: - `listQueries(params?: ListQueriesParams, client?: OpenProjectClient): Promise>` - `getQuery(id: number, client?: OpenProjectClient): Promise` - `getQueryResults(id: number, params?: QueryResultsParams, client?: OpenProjectClient): Promise>` - `ListQueriesParams`, `QueryResultsParams` interfaces - [x] **Step 1: Write failing tests for queries service** Append to `tests/services.test.ts`: ```typescript import { listQueries, getQuery, getQueryResults } from "../src/services/queries.ts"; 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"); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/services.test.ts` Expected: FAIL with module not found `../src/services/queries.ts` - [x] **Step 3: Write minimal implementation** In `src/services/queries.ts`: ```typescript /** * Queries Domain Service. */ import type { OpenProjectClient } from "../client/api-client.ts"; import { normalizeQuery, normalizeQuerySummary, normalizeWorkPackageSummary, unpackCollection, } from "../client/hal-parser.ts"; import type { HalCollection, HalResource, PaginatedResult, QueryDetail, QuerySummary, WorkPackageSummary, } from "../client/types.ts"; import { resolveClient } from "./helper.ts"; export interface ListQueriesParams { projectId?: number | string; pageSize?: number; offset?: number; } export interface QueryResultsParams { pageSize?: number; offset?: number; } /** * Lists saved queries, optionally filtered by project. */ export async function listQueries( params?: ListQueriesParams, 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?.projectId !== undefined) { query.filters = JSON.stringify([ { project: { operator: "=", values: [String(params.projectId)] } }, ]); } const response = await opClient.get>("queries", query); return unpackCollection(response, normalizeQuerySummary); } /** * Retrieves details and structure of a saved query by its ID. */ export async function getQuery( id: number, client?: OpenProjectClient ): Promise { const opClient = resolveClient(client); const response = await opClient.get(`queries/${id}`); return normalizeQuery(response); } /** * Executes a saved query and returns its resulting work packages. */ export async function getQueryResults( id: number, params?: QueryResultsParams, 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; const response = await opClient.get>( `queries/${id}/results`, query ); return unpackCollection(response, normalizeWorkPackageSummary); } ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/services.test.ts` Expected: PASS - [x] **Step 5: Commit** ```bash git add src/services/queries.ts tests/services.test.ts git commit -m "feat(services): implement queries domain service" ``` --- ### Task 5: Metadata Service (`src/services/metadata.ts`) and Unified Index **Files:** - Create: `src/services/metadata.ts` - Create: `src/services/index.ts` - Modify: `tests/services.test.ts` **Interfaces:** - Consumes: - `resolveClient` from `src/services/helper.ts` - `OpenProjectClient` from `src/client/api-client.ts` - `unpackCollection`, `normalizeStatus`, `normalizeType`, `normalizePriority`, `normalizeUser` from `src/client/hal-parser.ts` - `StatusItem`, `TypeItem`, `PriorityItem`, `UserItem`, `PaginatedResult`, `HalCollection`, `HalResource` from `src/client/types.ts` - Produces: - `listStatuses(client?: OpenProjectClient): Promise` - `listTypes(params?: ListTypesParams, client?: OpenProjectClient): Promise` - `listPriorities(client?: OpenProjectClient): Promise` - `listUsers(params?: ListUsersParams, client?: OpenProjectClient): Promise>` - `ListTypesParams`, `ListUsersParams` interfaces - Barrel exports in `src/services/index.ts` - [x] **Step 1: Write failing tests for metadata service** Append to `tests/services.test.ts`: ```typescript import { listStatuses, listTypes, listPriorities, listUsers, } from "../src/services/metadata.ts"; import * as domainServices from "../src/services/index.ts"; 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("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.listWorkPackages).toBe("function"); expect(typeof domainServices.getWorkPackage).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"); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `bun test tests/services.test.ts` Expected: FAIL with module not found `../src/services/metadata.ts` - [x] **Step 3: Write minimal implementation** In `src/services/metadata.ts`: ```typescript /** * Metadata & Taxonomies Domain Service. */ import type { OpenProjectClient } from "../client/api-client.ts"; import { normalizePriority, normalizeStatus, normalizeType, normalizeUser, unpackCollection, } from "../client/hal-parser.ts"; import type { HalCollection, HalResource, PaginatedResult, PriorityItem, StatusItem, TypeItem, UserItem, } from "../client/types.ts"; import { resolveClient } from "./helper.ts"; export interface ListTypesParams { projectId?: number | string; } export interface ListUsersParams { pageSize?: number; offset?: number; status?: string; } /** * Lists all work package statuses available in the system. */ export async function listStatuses( client?: OpenProjectClient ): Promise { const opClient = resolveClient(client); const response = await opClient.get>("statuses"); const unpacked = unpackCollection(response, normalizeStatus); return unpacked.items; } /** * Lists work package types, optionally filtered to those enabled for a specific project. */ export async function listTypes( params?: ListTypesParams, client?: OpenProjectClient ): Promise { const opClient = resolveClient(client); const path = params?.projectId !== undefined ? `projects/${params.projectId}/types` : "types"; const response = await opClient.get>(path); const unpacked = unpackCollection(response, normalizeType); return unpacked.items; } /** * Lists issue priority levels configured in the system. */ export async function listPriorities( client?: OpenProjectClient ): Promise { const opClient = resolveClient(client); const response = await opClient.get>("priorities"); const unpacked = unpackCollection(response, normalizePriority); return unpacked.items; } /** * Lists users in the OpenProject instance with pagination. */ export async function listUsers( params?: ListUsersParams, 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?.status !== undefined) query.status = params.status; const response = await opClient.get>("users", query); return unpackCollection(response, normalizeUser); } ``` In `src/services/index.ts`: ```typescript /** * Domain Services Barrel Export. */ export * from "./helper.ts"; export * from "./projects.ts"; export * from "./work-packages.ts"; export * from "./queries.ts"; export * from "./metadata.ts"; ``` - [x] **Step 4: Run test to verify it passes** Run: `bun test tests/services.test.ts` Expected: PASS - [x] **Step 5: Commit** ```bash git add src/services/metadata.ts src/services/index.ts tests/services.test.ts git commit -m "feat(services): implement metadata domain service and barrel export" ``` --- ### Task 6: Live Container Integration Verification & Documentation Update **Files:** - Modify: `tests/services.test.ts` - Modify: `docs/TODO.md` **Interfaces:** - Consumes: All services from `src/services/index.ts` - Produces: Live validation suite against OpenProject 17 docker container - [x] **Step 1: Write live integration test suite** In `tests/services.test.ts`, add live integration tests that run when local OpenProject container is reachable: ```typescript 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")); 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); }); }); ``` - [x] **Step 2: Run full test suite to verify all tests pass** Run: `bun test` Expected: PASS (All tests in client.test.ts, smoke.test.ts, and services.test.ts pass) - [x] **Step 3: Update `docs/TODO.md`** Update `docs/TODO.md` to mark Task 2 items completed. - [x] **Step 4: Commit** ```bash git add tests/services.test.ts docs/TODO.md git commit -m "feat(services): add live container tests and mark Task 2 complete in TODO.md" ```