The completion guard added in a8d3c23 read `cacheMap.size > 0`, but
`cacheMap` is the tenant-wide map that `getWikiPage` also writes to on
every single-page fetch. One prior `openproject_get_wiki_page` call on
the same cache key therefore leaves the map non-empty before the probe
loop issues its first request, and a scan in which every probe fails
with a 500 inherits that entry and marks itself discovery-complete
anyway — permanently, for the process lifetime.
"Get a wiki page, then search" is an ordinary LLM tool sequence, so the
contaminated path is likely rather than exotic, and it lands back in
exactly the failure this branch exists to eliminate: wiki search returns
[] forever and the model tells the user their pages do not exist.
Completion is now judged on growth during the scan (`cacheMap.size >
cachedBefore`), captured before the probe loop. The cold-cache
behaviour is unchanged, since `cachedBefore` is then 0 and the condition
is equivalent — both existing F5b tests stay green.
A permanently-failing instance now re-probes on every search. That is
the accepted cost of retrying rather than caching a lie.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
938 lines
33 KiB
TypeScript
938 lines
33 KiB
TypeScript
import { describe, expect, test, beforeEach } from "bun:test";
|
|
import {
|
|
OpenProjectAuthenticationError,
|
|
OpenProjectClient,
|
|
OpenProjectError,
|
|
} from "../src/client/api-client.ts";
|
|
import { runWithContext, type RequestContext } from "../src/context.ts";
|
|
import {
|
|
clearWikiCache,
|
|
getWikiPage,
|
|
listWikiPageLinks,
|
|
searchWikiPages,
|
|
} from "../src/services/wikis.ts";
|
|
|
|
describe("Wikis Service", () => {
|
|
beforeEach(() => {
|
|
clearWikiCache();
|
|
});
|
|
|
|
test("getWikiPage retrieves page and attaches files", async () => {
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path === "/api/v3/wiki_pages/1") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "Project Wiki",
|
|
_links: {
|
|
project: { href: "/api/v3/projects/1", title: "Demo Project" },
|
|
},
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1/attachments") {
|
|
return {
|
|
_type: "Collection",
|
|
total: 1,
|
|
_embedded: {
|
|
elements: [
|
|
{
|
|
_type: "Attachment",
|
|
id: 12,
|
|
fileName: "architecture.png",
|
|
fileSize: 45000,
|
|
contentType: "image/png",
|
|
_links: {
|
|
downloadLocation: { href: "/api/v3/attachments/12/content" },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected path: ${path}`);
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const page = await getWikiPage(1, mockClient);
|
|
expect(page.id).toBe(1);
|
|
expect(page.title).toBe("Project Wiki");
|
|
expect(page.project.id).toBe(1);
|
|
expect(page.attachments).toHaveLength(1);
|
|
expect(page.attachments[0]!.fileName).toBe("architecture.png");
|
|
expect(page.attachments[0]!.fileSize).toBe(45000);
|
|
expect(page.attachments[0]!.contentType).toBe("image/png");
|
|
expect(page.attachments[0]!.downloadUrl).toBe("/api/v3/attachments/12/content");
|
|
});
|
|
|
|
test("getWikiPage handles missing attachments collection gracefully", async () => {
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path === "/api/v3/wiki_pages/2") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 2,
|
|
title: "Quick Notes",
|
|
_links: {
|
|
project: { href: "/api/v3/projects/3", title: "Core App" },
|
|
},
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/2/attachments") {
|
|
const err = new Error("Attachments not found");
|
|
(err as unknown as { statusCode: number }).statusCode = 404;
|
|
throw err;
|
|
}
|
|
throw new Error(`Unexpected path: ${path}`);
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const page = await getWikiPage(2, mockClient);
|
|
expect(page.id).toBe(2);
|
|
expect(page.title).toBe("Quick Notes");
|
|
expect(page.project.id).toBe(3);
|
|
expect(page.attachments).toEqual([]);
|
|
});
|
|
|
|
test("listWikiPageLinks retrieves global wiki page links when workPackageId is omitted", async () => {
|
|
let requestedPath = "";
|
|
let requestedQuery: Record<string, unknown> | undefined;
|
|
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string, query?: Record<string, unknown>) => {
|
|
requestedPath = path;
|
|
requestedQuery = query;
|
|
return {
|
|
_type: "Collection",
|
|
total: 1,
|
|
count: 1,
|
|
pageSize: 20,
|
|
offset: 1,
|
|
_embedded: {
|
|
elements: [
|
|
{
|
|
_type: "WikiPageLink",
|
|
id: 10,
|
|
pageTitle: "Design Guidelines",
|
|
pageUrl: "http://localhost:8080/projects/demo/wiki/design",
|
|
provider: "openproject",
|
|
_links: {
|
|
workPackage: { href: "/api/v3/work_packages/42", title: "Refactor UI" },
|
|
wikiPage: { href: "/api/v3/wiki_pages/5", title: "Design Guidelines" },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const result = await listWikiPageLinks({ offset: 1, pageSize: 20 }, mockClient);
|
|
expect(requestedPath).toBe("/api/v3/wiki_page_links");
|
|
expect(requestedQuery?.offset).toBe(1);
|
|
expect(requestedQuery?.pageSize).toBe(20);
|
|
expect(result.total).toBe(1);
|
|
expect(result.elements).toHaveLength(1);
|
|
expect(result.elements[0]!.id).toBe(10);
|
|
expect(result.elements[0]!.pageTitle).toBe("Design Guidelines");
|
|
expect(result.elements[0]!.pageUrl).toBe("http://localhost:8080/projects/demo/wiki/design");
|
|
expect(result.elements[0]!.workPackage?.id).toBe(42);
|
|
expect(result.elements[0]!.workPackage?.title).toBe("Refactor UI");
|
|
expect(result.items).toHaveLength(1);
|
|
});
|
|
|
|
test("listWikiPageLinks queries work package specific endpoint when workPackageId is provided", async () => {
|
|
let requestedPath = "";
|
|
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
requestedPath = path;
|
|
return {
|
|
_type: "Collection",
|
|
total: 0,
|
|
count: 0,
|
|
pageSize: 10,
|
|
offset: 1,
|
|
_embedded: {
|
|
elements: [],
|
|
},
|
|
};
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const result = await listWikiPageLinks({ workPackageId: 99 }, mockClient);
|
|
expect(requestedPath).toBe("/api/v3/work_packages/99/wiki_page_links");
|
|
expect(result.total).toBe(0);
|
|
expect(result.elements).toEqual([]);
|
|
});
|
|
|
|
test("searchWikiPages matches discovered pages by title substring", async () => {
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path.startsWith("/api/v3/wiki_page_links")) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "System Architecture",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1/attachments") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path === "/api/v3/wiki_pages/2") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 2,
|
|
title: "Sprint Retrospective",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/2/attachments") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
const err = new Error("Not found");
|
|
(err as unknown as { statusCode: number }).statusCode = 404;
|
|
throw err;
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const results = await searchWikiPages({ query: "architecture" }, mockClient);
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.title).toBe("System Architecture");
|
|
expect(results[0]!.id).toBe(1);
|
|
expect(results[0]!.project.id).toBe(1);
|
|
expect(results[0]!.attachmentsCount).toBe(0);
|
|
});
|
|
|
|
test("searchWikiPages filters by numeric projectId and string identifier", async () => {
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path.startsWith("/api/v3/wiki_page_links")) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "Project One Architecture",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Project 1" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1/attachments") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path === "/api/v3/wiki_pages/2") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 2,
|
|
title: "Project Two Architecture",
|
|
_links: { project: { href: "/api/v3/projects/2", title: "Project 2" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/2/attachments") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path === "projects/proj-two" || path === "/api/v3/projects/proj-two") {
|
|
return { id: 2 };
|
|
}
|
|
const err = new Error("Not found");
|
|
(err as unknown as { statusCode: number }).statusCode = 404;
|
|
throw err;
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
// Filter by numeric projectId = 1
|
|
const resultsProject1 = await searchWikiPages({ projectId: 1 }, mockClient);
|
|
expect(resultsProject1).toHaveLength(1);
|
|
expect(resultsProject1[0]!.id).toBe(1);
|
|
|
|
// Filter by string identifier "proj-two" (resolves to ID 2)
|
|
const resultsProject2 = await searchWikiPages({ projectId: "proj-two" }, mockClient);
|
|
expect(resultsProject2).toHaveLength(1);
|
|
expect(resultsProject2[0]!.id).toBe(2);
|
|
});
|
|
|
|
test("searchWikiPages respects limit parameter", async () => {
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path.startsWith("/api/v3/wiki_page_links")) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "Docs Page 1",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Project 1" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1/attachments") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path === "/api/v3/wiki_pages/2") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 2,
|
|
title: "Docs Page 2",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Project 1" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/2/attachments") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
const err = new Error("Not found");
|
|
(err as unknown as { statusCode: number }).statusCode = 404;
|
|
throw err;
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const results = await searchWikiPages({ query: "Docs", limit: 1 }, mockClient);
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.id).toBe(1);
|
|
});
|
|
|
|
test("searchWikiPages caches discovered pages and serves subsequent searches without probing", async () => {
|
|
let probeCount = 0;
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path.startsWith("/api/v3/wiki_page_links")) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1") {
|
|
probeCount++;
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "API Architecture Guide",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1/attachments") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
const err = new Error("Not found");
|
|
(err as unknown as { statusCode: number }).statusCode = 404;
|
|
throw err;
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
// First search: performs discovery
|
|
const firstResults = await searchWikiPages({ query: "Architecture" }, mockClient);
|
|
expect(firstResults).toHaveLength(1);
|
|
expect(probeCount).toBe(1);
|
|
|
|
// Second search: served from in-memory cache without hitting /api/v3/wiki_pages/1 again
|
|
const secondResults = await searchWikiPages({ query: "Guide" }, mockClient);
|
|
expect(secondResults).toHaveLength(1);
|
|
expect(secondResults[0]!.id).toBe(1);
|
|
expect(probeCount).toBe(1);
|
|
|
|
// Third search with refreshCache: true forces re-discovery
|
|
const thirdResults = await searchWikiPages(
|
|
{ query: "Guide", refreshCache: true },
|
|
mockClient
|
|
);
|
|
expect(thirdResults).toHaveLength(1);
|
|
expect(probeCount).toBe(2);
|
|
});
|
|
|
|
test("resolves ambient RequestContext client when client parameter is omitted", async () => {
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path === "/api/v3/wiki_pages/1") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "Context Wiki",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1/attachments") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path.startsWith("/api/v3/wiki_page_links")) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
const err = new Error("Not found");
|
|
(err as unknown as { statusCode: number }).statusCode = 404;
|
|
throw err;
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const context: RequestContext = {
|
|
client: mockClient,
|
|
isReadOnly: false,
|
|
};
|
|
|
|
await runWithContext(context, async () => {
|
|
const page = await getWikiPage(1);
|
|
expect(page.title).toBe("Context Wiki");
|
|
|
|
const searchRes = await searchWikiPages({ query: "Context" });
|
|
expect(searchRes).toHaveLength(1);
|
|
expect(searchRes[0]!.title).toBe("Context Wiki");
|
|
});
|
|
});
|
|
|
|
test("searchWikiPages re-throws 401 authentication errors during discovery instead of swallowing", async () => {
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path.startsWith("/api/v3/wiki_page_links")) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
const err = new OpenProjectAuthenticationError("Invalid API key provided");
|
|
throw err;
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
expect(searchWikiPages({}, mockClient)).rejects.toThrow("Invalid API key provided");
|
|
});
|
|
|
|
test("searchWikiPages re-throws 403 and 429 errors during discovery probe", async () => {
|
|
const mockForbiddenClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path.startsWith("/api/v3/wiki_page_links")) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
const err = new Error("Access denied");
|
|
(err as unknown as { statusCode: number }).statusCode = 403;
|
|
throw err;
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
expect(searchWikiPages({}, mockForbiddenClient)).rejects.toThrow("Access denied");
|
|
|
|
const mockRateLimitClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path.startsWith("/api/v3/wiki_page_links")) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
const err = new Error("Too many requests");
|
|
(err as unknown as { statusCode: number }).statusCode = 429;
|
|
throw err;
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
expect(searchWikiPages({}, mockRateLimitClient)).rejects.toThrow("Too many requests");
|
|
});
|
|
|
|
test("searchWikiPages with refreshCache evicts stale deleted pages from cache", async () => {
|
|
let page2Exists = true;
|
|
const mockClient = {
|
|
baseUrl: "http://localhost:8080",
|
|
get: async (path: string) => {
|
|
if (path.startsWith("/api/v3/wiki_page_links")) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "First Page",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1/attachments") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (path === "/api/v3/wiki_pages/2" && page2Exists) {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 2,
|
|
title: "Second Page",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/2/attachments" && page2Exists) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
const err = new Error("Not found");
|
|
(err as unknown as { statusCode: number }).statusCode = 404;
|
|
throw err;
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const initialResults = await searchWikiPages({}, mockClient);
|
|
expect(initialResults).toHaveLength(2);
|
|
|
|
// Page 2 is subsequently deleted on OpenProject
|
|
page2Exists = false;
|
|
|
|
// Refreshing cache should clear stale page 2 from memory
|
|
const refreshedResults = await searchWikiPages({ refreshCache: true }, mockClient);
|
|
expect(refreshedResults).toHaveLength(1);
|
|
expect(refreshedResults[0]!.id).toBe(1);
|
|
});
|
|
|
|
test("maintains isolated wiki page cache across different clients with same baseUrl but different API keys", async () => {
|
|
let clientACalls = 0;
|
|
let clientBCalls = 0;
|
|
|
|
const mockFetchA = async (input: string | URL | Request) => {
|
|
clientACalls++;
|
|
const url = String(input);
|
|
if (url.includes("/api/v3/wiki_pages/1/attachments")) {
|
|
return new Response(
|
|
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
}
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "User A Confidential Wiki",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Project A" } },
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const mockFetchB = async (input: string | URL | Request) => {
|
|
clientBCalls++;
|
|
const url = String(input);
|
|
if (url.includes("/api/v3/wiki_pages/1/attachments")) {
|
|
return new Response(
|
|
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
}
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "User B Confidential Wiki",
|
|
_links: { project: { href: "/api/v3/projects/2", title: "Project B" } },
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
};
|
|
|
|
const clientA = new OpenProjectClient({
|
|
baseUrl: "https://openproject.example.com",
|
|
apiKey: "api-key-tenant-a",
|
|
fetchFn: mockFetchA,
|
|
});
|
|
|
|
const clientB = new OpenProjectClient({
|
|
baseUrl: "https://openproject.example.com",
|
|
apiKey: "api-key-tenant-b",
|
|
fetchFn: mockFetchB,
|
|
});
|
|
|
|
// Client A fetches wiki page 1 -> cached in client A's bucket
|
|
const pageA = await getWikiPage(1, clientA);
|
|
expect(pageA.title).toBe("User A Confidential Wiki");
|
|
expect(clientACalls).toBe(2);
|
|
|
|
// Client B fetches wiki page 1 -> must NOT use client A's cache
|
|
const pageB = await getWikiPage(1, clientB);
|
|
expect(pageB.title).toBe("User B Confidential Wiki");
|
|
expect(clientBCalls).toBe(2);
|
|
|
|
// Repeat fetch with client A -> returns User A data without refetching from server
|
|
const pageA2 = await getWikiPage(1, clientA);
|
|
expect(pageA2.title).toBe("User A Confidential Wiki");
|
|
});
|
|
|
|
test("maintains isolated wiki search discovery cache across different API keys", async () => {
|
|
const mockFetchA = async (input: string | URL | Request) => {
|
|
const url = String(input);
|
|
if (url.includes("/api/v3/wiki_page_links")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
total: 1,
|
|
_embedded: {
|
|
elements: [
|
|
{
|
|
id: 101,
|
|
_links: { wikiPage: { href: "/api/v3/wiki_pages/10" } },
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
}
|
|
if (url.includes("/api/v3/wiki_pages/10/attachments")) {
|
|
return new Response(
|
|
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
}
|
|
if (url.includes("/api/v3/wiki_pages/10")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "WikiPage",
|
|
id: 10,
|
|
title: "Tenant A Page",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Project A" } },
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
}
|
|
return new Response(JSON.stringify({ message: "Not found" }), {
|
|
status: 404,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const mockFetchB = async (input: string | URL | Request) => {
|
|
const url = String(input);
|
|
if (url.includes("/api/v3/wiki_page_links")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "Collection",
|
|
total: 1,
|
|
_embedded: {
|
|
elements: [
|
|
{
|
|
id: 201,
|
|
_links: { wikiPage: { href: "/api/v3/wiki_pages/20" } },
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
}
|
|
if (url.includes("/api/v3/wiki_pages/20/attachments")) {
|
|
return new Response(
|
|
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
}
|
|
if (url.includes("/api/v3/wiki_pages/20")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
_type: "WikiPage",
|
|
id: 20,
|
|
title: "Tenant B Page",
|
|
_links: { project: { href: "/api/v3/projects/2", title: "Project B" } },
|
|
}),
|
|
{ headers: { "Content-Type": "application/hal+json" } }
|
|
);
|
|
}
|
|
return new Response(JSON.stringify({ message: "Not found" }), {
|
|
status: 404,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const clientA = new OpenProjectClient({
|
|
baseUrl: "https://openproject.example.com",
|
|
apiKey: "api-key-tenant-a",
|
|
fetchFn: mockFetchA,
|
|
});
|
|
|
|
const clientB = new OpenProjectClient({
|
|
baseUrl: "https://openproject.example.com",
|
|
apiKey: "api-key-tenant-b",
|
|
fetchFn: mockFetchB,
|
|
});
|
|
|
|
const searchResultsA = await searchWikiPages({}, clientA);
|
|
expect(searchResultsA).toHaveLength(1);
|
|
expect(searchResultsA[0]!.title).toBe("Tenant A Page");
|
|
|
|
const searchResultsB = await searchWikiPages({}, clientB);
|
|
expect(searchResultsB).toHaveLength(1);
|
|
expect(searchResultsB[0]!.title).toBe("Tenant B Page");
|
|
});
|
|
});
|
|
|
|
describe("Wiki Fuzzy Search", () => {
|
|
function wikiFixtureClient() {
|
|
const pages: Record<string, unknown> = {
|
|
"/api/v3/wiki_pages/1": {
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "Onboarding Guide",
|
|
text: { raw: "New engineers should request VPN access on their first day." },
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo project" } },
|
|
},
|
|
"/api/v3/wiki_pages/2": {
|
|
_type: "WikiPage",
|
|
id: 2,
|
|
title: "Deployment Runbook",
|
|
text: { raw: "Roll back with the previous image tag if smoke tests fail." },
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo project" } },
|
|
},
|
|
};
|
|
|
|
return {
|
|
getCacheKey: () => `test-${Math.random()}`,
|
|
baseUrl: "http://localhost",
|
|
get: async (path: string) => {
|
|
if (path.startsWith("/api/v3/wiki_page_links")) {
|
|
return { _type: "Collection", _embedded: { elements: [] } };
|
|
}
|
|
if (path.endsWith("/attachments")) {
|
|
return { _type: "Collection", _embedded: { elements: [] } };
|
|
}
|
|
if (pages[path]) {
|
|
return pages[path];
|
|
}
|
|
throw new OpenProjectError("not found", { statusCode: 404 });
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
}
|
|
|
|
test("retains page body text when normalizing", async () => {
|
|
const page = await getWikiPage(1, wikiFixtureClient());
|
|
expect(page.text).toContain("VPN access");
|
|
});
|
|
|
|
test("finds a page by body text, not just title", async () => {
|
|
const results = await searchWikiPages({ query: "vpn access" }, wikiFixtureClient());
|
|
expect(results[0]!.id).toBe(1);
|
|
expect(results[0]!.matchedFields).toContain("text");
|
|
});
|
|
|
|
test("ranks a title match above a body match", async () => {
|
|
const results = await searchWikiPages({ query: "deployment" }, wikiFixtureClient());
|
|
expect(results[0]!.id).toBe(2);
|
|
});
|
|
|
|
test("tolerates a typo in the query", async () => {
|
|
const results = await searchWikiPages({ query: "onboardng" }, wikiFixtureClient());
|
|
expect(results[0]!.id).toBe(1);
|
|
});
|
|
|
|
test("returns a snippet centred on the body match", async () => {
|
|
const results = await searchWikiPages({ query: "smoke tests" }, wikiFixtureClient());
|
|
expect(results[0]!.snippet).toContain("smoke tests");
|
|
});
|
|
|
|
test("exact mode finds nothing for a typo", async () => {
|
|
const results = await searchWikiPages(
|
|
{ query: "onboardng", matchMode: "exact" },
|
|
wikiFixtureClient()
|
|
);
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("Wiki Error Propagation", () => {
|
|
beforeEach(() => {
|
|
clearWikiCache();
|
|
});
|
|
|
|
function pageWithFailingAttachments(error: unknown) {
|
|
return {
|
|
baseUrl: "http://localhost:8080",
|
|
getCacheKey: () => "attachments-test",
|
|
get: async (path: string) => {
|
|
if (path.endsWith("/attachments")) {
|
|
throw error;
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "Project Wiki",
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo Project" } },
|
|
};
|
|
}
|
|
throw new OpenProjectError("not found", { statusCode: 404 });
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
}
|
|
|
|
test("getWikiPage propagates a rate limit from the attachments fetch", async () => {
|
|
await expect(
|
|
getWikiPage(
|
|
1,
|
|
pageWithFailingAttachments(new OpenProjectError("rate limited", { statusCode: 429 }))
|
|
)
|
|
).rejects.toThrow("rate limited");
|
|
});
|
|
|
|
test("getWikiPage propagates an authentication failure from the attachments fetch", async () => {
|
|
await expect(
|
|
getWikiPage(1, pageWithFailingAttachments(new OpenProjectAuthenticationError()))
|
|
).rejects.toThrow(OpenProjectAuthenticationError);
|
|
});
|
|
|
|
test("getWikiPage still degrades to an empty attachment list on a non-fatal failure", async () => {
|
|
const page = await getWikiPage(
|
|
1,
|
|
pageWithFailingAttachments(new OpenProjectError("boom", { statusCode: 500 }))
|
|
);
|
|
expect(page.id).toBe(1);
|
|
expect(page.attachments).toEqual([]);
|
|
});
|
|
|
|
test("a discovery scan that errors through every probe is not marked complete", async () => {
|
|
// Phase 1: every wiki page probe fails with a 500. Nothing is cached, and
|
|
// the 404 cutoff never trips, so discovery did not actually complete.
|
|
let failEverything = true;
|
|
const client = {
|
|
baseUrl: "http://localhost:8080",
|
|
getCacheKey: () => "flaky-tenant",
|
|
get: async (path: string) => {
|
|
if (path === "/api/v3/wiki_page_links") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
if (failEverything) {
|
|
throw new OpenProjectError("upstream exploded", { statusCode: 500 });
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1") {
|
|
return {
|
|
_type: "WikiPage",
|
|
id: 1,
|
|
title: "Deployment Runbook",
|
|
text: { raw: "How to deploy the service" },
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo Project" } },
|
|
};
|
|
}
|
|
if (path === "/api/v3/wiki_pages/1/attachments") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
throw new OpenProjectError("not found", { statusCode: 404 });
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
const first = await searchWikiPages({ query: "deployment" }, client);
|
|
expect(first).toEqual([]);
|
|
|
|
// Phase 2: the instance recovers. Discovery must run again rather than
|
|
// short-circuiting on a cache that was wrongly marked complete and
|
|
// returning [] for the life of the process.
|
|
failEverything = false;
|
|
const second = await searchWikiPages({ query: "deployment" }, client);
|
|
expect(second.map((page) => page.id)).toEqual([1]);
|
|
});
|
|
|
|
test("a genuinely empty wiki is marked complete and is not re-probed", async () => {
|
|
let probes = 0;
|
|
const client = {
|
|
baseUrl: "http://localhost:8080",
|
|
getCacheKey: () => "empty-tenant",
|
|
get: async (path: string) => {
|
|
if (path === "/api/v3/wiki_page_links") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
probes++;
|
|
throw new OpenProjectError("not found", { statusCode: 404 });
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
|
|
expect(await searchWikiPages({ query: "anything" }, client)).toEqual([]);
|
|
const afterFirst = probes;
|
|
// The 404 cutoff is a real completion signal: five consecutive misses.
|
|
expect(afterFirst).toBe(5);
|
|
|
|
expect(await searchWikiPages({ query: "anything" }, client)).toEqual([]);
|
|
expect(probes).toBe(afterFirst);
|
|
});
|
|
});
|
|
|
|
describe("Wiki Discovery Completion With A Primed Cache", () => {
|
|
beforeEach(() => {
|
|
clearWikiCache();
|
|
});
|
|
|
|
const CACHE_KEY = "primed-tenant";
|
|
|
|
const pages: Record<number, { title: string; text: string }> = {
|
|
1: { title: "Onboarding", text: "How to get started" },
|
|
2: { title: "Deployment Runbook", text: "How to deploy the service" },
|
|
};
|
|
|
|
/** Serves the fixture wiki. Shares a cache key with the failing client. */
|
|
function healthyClient(probedIds: number[] = []) {
|
|
return {
|
|
baseUrl: "http://localhost:8080",
|
|
getCacheKey: () => CACHE_KEY,
|
|
get: async (path: string) => {
|
|
if (path === "/api/v3/wiki_page_links") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
const id = Number(path.match(/wiki_pages\/(\d+)/)?.[1] ?? NaN);
|
|
if (path.endsWith("/attachments")) {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
probedIds.push(id);
|
|
const page = pages[id];
|
|
if (!page) {
|
|
throw new OpenProjectError("not found", { statusCode: 404 });
|
|
}
|
|
return {
|
|
_type: "WikiPage",
|
|
id,
|
|
title: page.title,
|
|
text: { raw: page.text },
|
|
_links: { project: { href: "/api/v3/projects/1", title: "Demo Project" } },
|
|
};
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
}
|
|
|
|
/** Same tenant, but every wiki page probe fails non-fatally. */
|
|
function outageClient(probedIds: number[] = []) {
|
|
return {
|
|
baseUrl: "http://localhost:8080",
|
|
getCacheKey: () => CACHE_KEY,
|
|
get: async (path: string) => {
|
|
if (path === "/api/v3/wiki_page_links") {
|
|
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
|
}
|
|
const id = Number(path.match(/wiki_pages\/(\d+)/)?.[1] ?? NaN);
|
|
probedIds.push(id);
|
|
throw new OpenProjectError("upstream exploded", { statusCode: 500 });
|
|
},
|
|
} as unknown as OpenProjectClient;
|
|
}
|
|
|
|
/**
|
|
* `getWikiPage` writes into the same tenant-wide cache map that discovery
|
|
* populates, so "get a wiki page, then search" — an entirely ordinary LLM
|
|
* tool sequence — leaves the map non-empty before the probe loop has issued
|
|
* a single request. Judging completion on absolute cache size therefore
|
|
* lets an all-errors scan inherit that entry and mark itself complete, and
|
|
* wiki search then returns [] for the life of the process.
|
|
*/
|
|
test("a primed cache does not let an all-errors scan mark discovery complete", async () => {
|
|
// 1. Prime the tenant cache with one successful single-page fetch.
|
|
const primed = await getWikiPage(1, healthyClient());
|
|
expect(primed.title).toBe("Onboarding");
|
|
|
|
// 2. A discovery scan in which every probe fails non-fatally. It caches
|
|
// nothing new and the 404 cutoff never trips, so discovery did NOT
|
|
// complete — even though the map is non-empty thanks to step 1.
|
|
const outageProbes: number[] = [];
|
|
const duringOutage = await searchWikiPages({ query: "deployment" }, outageClient(outageProbes));
|
|
expect(outageProbes.length).toBeGreaterThan(0);
|
|
expect(duringOutage).toEqual([]);
|
|
|
|
// 3. The instance recovers. Discovery must run again rather than
|
|
// short-circuiting on a cache that was wrongly marked complete.
|
|
const recoveryProbes: number[] = [];
|
|
const afterRecovery = await searchWikiPages(
|
|
{ query: "deployment" },
|
|
healthyClient(recoveryProbes)
|
|
);
|
|
|
|
expect(recoveryProbes.length).toBeGreaterThan(0);
|
|
expect(afterRecovery.map((page) => page.id)).toEqual([2]);
|
|
});
|
|
});
|