diff --git a/src/services/projects.ts b/src/services/projects.ts new file mode 100644 index 0000000..76a6dbf --- /dev/null +++ b/src/services/projects.ts @@ -0,0 +1,69 @@ +/** + * 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"); +} diff --git a/tests/services.test.ts b/tests/services.test.ts index c51e73a..085363c 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { resolveClient } from "../src/services/helper.ts"; +import { listProjects, getProject, getProjectSchema } from "../src/services/projects.ts"; import { OpenProjectClient } from "../src/client/api-client.ts"; import { runWithContext, type RequestContext } from "../src/context.ts"; @@ -33,3 +34,96 @@ describe("Domain Services Helper", () => { expect(() => resolveClient()).toThrow("No active RequestContext found"); }); }); + +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"); + }); +}); +