`getWikiPage`'s attachments fetch had a bare `catch` that swallowed 401/403/429 one function away from discovery code that correctly rethrows them. During a 50-page discovery scan a rate limit was eaten once per page and every page cached with `attachments: []`, then reported to the model as fact. The discovery probe loop incremented `consecutiveMisses` only on 404, so a run where every probe failed with a 500 or a network error never tripped the cutoff, never rethrew, and cached nothing — yet still ran `discoveredCacheKeys.add(cacheKey)` unconditionally, marking discovery complete. Every later wiki search then short-circuited on an empty corpus and returned `[]` for the life of the process. Discovery is now marked complete only when at least one page was cached or the loop ended via the 404 cutoff. An all-errors scan satisfies neither, so it retries; a genuinely empty wiki trips the cutoff and is still marked complete rather than re-probed on every search. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
847 lines
30 KiB
TypeScript
847 lines
30 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);
|
|
});
|
|
});
|