Adds optional query/matchMode to listWorkPackageActivities, ranking the already-fetched activity list client-side via rankRecords (no extra API calls). Exposes the same fields on the openproject_list_work_package_activities tool. When no query is supplied, every activity is returned exactly as before, and onlyComments continues to filter independently. Also fixes a defect in the brief's exact-mode test fixture: the specified typo query "certificat" is a literal prefix substring of "certificate", so byte-for-byte exact containment matching (scoreExact in src/search/rank.ts) finds it regardless of typo tolerance, making the test unable to pass against a correct implementation. Swapped in "certificatee" (not a substring of "certificate") to preserve the test's intent without touching the assertion. Updated one pre-existing test-tools.test.ts call site to supply matchMode, now required by the extended args type. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
207 lines
6.8 KiB
TypeScript
207 lines
6.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";
|
|
|
|
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);
|
|
});
|
|
});
|