diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..836b90d --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,9 @@ +/** + * 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"; diff --git a/src/services/metadata.ts b/src/services/metadata.ts new file mode 100644 index 0000000..4f64eea --- /dev/null +++ b/src/services/metadata.ts @@ -0,0 +1,88 @@ +/** + * 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); +} diff --git a/tests/services.test.ts b/tests/services.test.ts index 980cc8f..c045861 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -7,6 +7,13 @@ import { 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 } from "../src/client/api-client.ts"; import { runWithContext, type RequestContext } from "../src/context.ts"; @@ -383,3 +390,129 @@ describe("Queries Service", () => { 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("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.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"); + }); +}); +