From 8db5a92bc5dae7fb9ba90f4ff8eddd629b73fc58 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 5 Sep 2026 22:49:18 +0200 Subject: [PATCH] feat(services): implement queries domain service --- src/services/queries.ts | 87 ++++++++++++++++++++++++++ tests/services.test.ts | 132 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/services/queries.ts diff --git a/src/services/queries.ts b/src/services/queries.ts new file mode 100644 index 0000000..1885530 --- /dev/null +++ b/src/services/queries.ts @@ -0,0 +1,87 @@ +/** + * 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); +} diff --git a/tests/services.test.ts b/tests/services.test.ts index bfec654..980cc8f 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -6,6 +6,7 @@ import { getWorkPackage, searchWorkPackages, } from "../src/services/work-packages.ts"; +import { listQueries, getQuery, getQueryResults } from "../src/services/queries.ts"; import { OpenProjectClient } from "../src/client/api-client.ts"; import { runWithContext, type RequestContext } from "../src/context.ts"; @@ -250,4 +251,135 @@ describe("Work Packages Service", () => { }); }); +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"); + }); +});