Files
openproject-mcp/tests/work-package-activities.test.ts
Lucas WintherandClaude Opus 5 e76cfa750b docs(search): say that the activities query filters as well as ranks
`query` on `openproject_list_work_package_activities` returns
`rankRecords(...)`, which drops everything below `DEFAULT_MIN_SCORE`, so
it filters as well as ranks. The schema description, README, and ADR-019
all said "rank". A model told "rank" passes a query to a list tool
expecting a reordered full history and then reasons over a silently
truncated timeline.

Behaviour is unchanged — filtering is the more useful behaviour and the
tests pin it. Only the wording is corrected, plus tests that keep the
wording from regressing. Also records the response-shape and pagination
changes from the preceding commits in ADR-019 and the README.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 09:27:33 +02:00

263 lines
8.8 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { OpenProjectClient } from "../src/client/api-client.ts";
import { runWithContext, type RequestContext } from "../src/context.ts";
import { listWorkPackageActivities } from "../src/services/work-packages.ts";
import {
listWorkPackageActivitiesShape,
listWorkPackageActivitiesTool,
} from "../src/tools/work-packages.ts";
describe("Work Package Activities Service", () => {
test("parses activities and separates comments from field changes", async () => {
const mockClient = {
get: async (path: string) => {
expect(path).toBe("/api/v3/work_packages/38/activities");
return {
_type: "Collection",
total: 2,
count: 2,
_embedded: {
elements: [
{
_type: "Activity",
id: 60,
version: 1,
createdAt: "2026-09-05T14:24:56Z",
comment: { raw: "", html: "" },
details: [
{ format: "custom", raw: "Status set to In progress" }
],
_links: {
user: { href: "/api/v3/users/2", title: "Admin User" }
}
},
{
_type: "Activity",
id: 61,
version: 2,
createdAt: "2026-09-05T15:00:00Z",
comment: { raw: "Fixed in commit abc1234", html: "<p>Fixed</p>" },
details: [],
_links: {
user: { href: "/api/v3/users/3", title: "Dev User" }
}
}
]
}
};
}
} as unknown as OpenProjectClient;
const all = await listWorkPackageActivities({ workPackageId: 38 }, mockClient);
expect(all).toHaveLength(2);
expect(all[0]!.isComment).toBe(false);
expect(all[1]!.isComment).toBe(true);
expect(all[1]!.comment).toBe("Fixed in commit abc1234");
expect(all[1]!.user?.name).toBe("Dev User");
const commentsOnly = await listWorkPackageActivities({ workPackageId: 38, onlyComments: true }, mockClient);
expect(commentsOnly).toHaveLength(1);
expect(commentsOnly[0]!.id).toBe(61);
});
test("handles empty activities collection and fallback user formatting", async () => {
const mockClient = {
get: async () => ({
_type: "Collection",
total: 1,
count: 1,
_embedded: {
elements: [
{
id: 99,
version: 1,
createdAt: "2026-09-05T16:00:00Z",
_links: {
user: { href: "/api/v3/users/42" }
}
}
]
}
})
} as unknown as OpenProjectClient;
const res = await listWorkPackageActivities({ workPackageId: 10 }, mockClient);
expect(res).toHaveLength(1);
expect(res[0]!.id).toBe(99);
expect(res[0]!.user).toEqual({ id: 42, name: "User #42" });
expect(res[0]!.comment).toBeUndefined();
expect(res[0]!.isComment).toBe(false);
expect(res[0]!.details).toEqual([]);
});
test("handles missing _embedded or empty elements gracefully", async () => {
const mockClient = {
get: async () => ({
_type: "Collection",
total: 0,
count: 0
})
} as unknown as OpenProjectClient;
const res = await listWorkPackageActivities({ workPackageId: 10 }, mockClient);
expect(res).toEqual([]);
});
test("resolves ambient RequestContext client when client parameter is omitted", async () => {
let calledPath = "";
const ambientClient = {
get: async (path: string) => {
calledPath = path;
return {
_type: "Collection",
total: 1,
count: 1,
_embedded: {
elements: [
{
id: 101,
version: 1,
createdAt: "2026-09-07T00:00:00Z",
comment: { raw: "Ambient resolution test comment" },
details: [],
},
],
},
};
},
} as unknown as OpenProjectClient;
const context: RequestContext = {
client: ambientClient,
isReadOnly: false,
};
await runWithContext(context, async () => {
const res = await listWorkPackageActivities({ workPackageId: 77 });
expect(calledPath).toBe("/api/v3/work_packages/77/activities");
expect(res).toHaveLength(1);
expect(res[0]!.id).toBe(101);
expect(res[0]!.comment).toBe("Ambient resolution test comment");
expect(res[0]!.isComment).toBe(true);
});
});
});
describe("Activity Ranking", () => {
function activitiesClient() {
return {
get: async () => ({
_type: "Collection",
_embedded: {
elements: [
{
id: 1,
version: 1,
createdAt: "2026-09-01T00:00:00Z",
comment: { raw: "Deployment blocked by the expired TLS certificate" },
_links: { user: { href: "/api/v3/users/4", title: "Admin" } },
},
{
id: 2,
version: 2,
createdAt: "2026-09-02T00:00:00Z",
comment: { raw: "Merged the dependency bump" },
_links: { user: { href: "/api/v3/users/5", title: "Dev" } },
},
],
},
}),
} as unknown as OpenProjectClient;
}
test("ranks activities by relevance when a query is supplied", async () => {
const result = await listWorkPackageActivities(
{ workPackageId: 1, query: "TLS certificate" },
activitiesClient()
);
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe(1);
});
test("tolerates a typo in the query", async () => {
const result = await listWorkPackageActivities(
{ workPackageId: 1, query: "certificat" },
activitiesClient()
);
expect(result[0]!.id).toBe(1);
});
test("returns every activity when no query is supplied", async () => {
const result = await listWorkPackageActivities({ workPackageId: 1 }, activitiesClient());
expect(result).toHaveLength(2);
});
test("exact mode finds nothing for a typo", async () => {
// Deliberately not "certificat" (the brief's original value): that string
// is a literal prefix substring of "certificate", so byte-for-byte exact
// containment matching (scoreExact in src/search/rank.ts, "reproducing
// the pre-fuzzy behaviour byte for byte") finds it regardless of typo
// tolerance, making the original fixture unable to exercise "exact mode
// rejects typos" against a correct implementation. "certificatee" (an
// extra trailing letter) is not a substring of "certificate", so it
// exercises the same intent without the collision.
const result = await listWorkPackageActivities(
{ workPackageId: 1, query: "certificatee", matchMode: "exact" },
activitiesClient()
);
expect(result).toHaveLength(0);
});
});
describe("Work Package Activities query documentation", () => {
/**
* `query` does not merely reorder the timeline — rankRecords drops
* everything below DEFAULT_MIN_SCORE. A model told the parameter "ranks"
* will reason over a silently truncated history, so the schema and the tool
* description must both say that it filters.
*/
test("the query parameter is documented as filtering, not only ranking", () => {
const description = listWorkPackageActivitiesShape.query.description ?? "";
expect(description.toLowerCase()).toContain("filter");
expect(description.toLowerCase()).toMatch(/not the complete history|are omitted/);
});
test("the tool description warns that a query narrows the timeline", () => {
expect(listWorkPackageActivitiesTool.description.toLowerCase()).toContain("filters");
});
test("a query really does drop non-matching activities", async () => {
const client = {
get: async () => ({
_type: "Collection",
_embedded: {
elements: [
{
id: 1,
version: 1,
createdAt: "2026-09-10T00:00:00Z",
comment: { raw: "Renewed the expired TLS certificate" },
_links: { user: { href: "/api/v3/users/4", title: "Admin" } },
},
{
id: 2,
version: 2,
createdAt: "2026-09-11T00:00:00Z",
comment: { raw: "Merged the dependency bump" },
_links: { user: { href: "/api/v3/users/5", title: "Dev" } },
},
],
},
}),
} as unknown as OpenProjectClient;
const all = await listWorkPackageActivities({ workPackageId: 1 }, client);
const filtered = await listWorkPackageActivities(
{ workPackageId: 1, query: "TLS certificate" },
client
);
expect(all).toHaveLength(2);
expect(filtered.map((a) => a.id)).toEqual([1]);
});
});