feat: add wikis domain service with caching discovery search
This commit is contained in:
@@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* Wikis Domain Service.
|
||||||
|
* Provides functions to inspect, search, and list OpenProject wiki pages and wiki page links.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { OpenProjectClient } from "../client/api-client.ts";
|
||||||
|
import { extractIdFromHref } from "../client/hal-parser.ts";
|
||||||
|
import type { HalCollection, HalLink, HalResource } from "../client/types.ts";
|
||||||
|
import { resolveClient, resolveProjectId } from "./helper.ts";
|
||||||
|
|
||||||
|
export interface WikiPageAttachment {
|
||||||
|
id: number;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
contentType: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiPageDetail {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
project: { id: number; identifier?: string; name?: string };
|
||||||
|
attachments: WikiPageAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiPageSummary {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
project: { id: number; identifier?: string; name?: string };
|
||||||
|
attachmentsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiPageLink {
|
||||||
|
id: number;
|
||||||
|
pageTitle?: string;
|
||||||
|
pageUrl?: string;
|
||||||
|
provider?: string;
|
||||||
|
workPackage?: { id: number; title?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
total: number;
|
||||||
|
count: number;
|
||||||
|
pageSize: number;
|
||||||
|
offset: number;
|
||||||
|
elements: T[];
|
||||||
|
items?: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListWikiPageLinksParams {
|
||||||
|
workPackageId?: number;
|
||||||
|
offset?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchWikiPagesParams {
|
||||||
|
query?: string;
|
||||||
|
projectId?: string | number;
|
||||||
|
limit?: number;
|
||||||
|
refreshCache?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache for discovered wiki pages keyed by client baseUrl
|
||||||
|
const wikiPagesCache = new Map<string, Map<number, WikiPageDetail>>();
|
||||||
|
const discoveredBaseUrls = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the in-memory wiki discovery cache.
|
||||||
|
*/
|
||||||
|
export function clearWikiCache(): void {
|
||||||
|
wikiPagesCache.clear();
|
||||||
|
discoveredBaseUrls.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a raw HAL attachment resource into a WikiPageAttachment.
|
||||||
|
*/
|
||||||
|
export function normalizeWikiAttachment(element: HalResource): WikiPageAttachment {
|
||||||
|
const links = (element._links ?? {}) as Record<string, HalLink | undefined>;
|
||||||
|
const downloadHref =
|
||||||
|
(links.downloadLocation as HalLink | undefined)?.href ??
|
||||||
|
(links.self as HalLink | undefined)?.href ??
|
||||||
|
"";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: element.id ?? extractIdFromHref(downloadHref) ?? 0,
|
||||||
|
fileName: String(element.fileName ?? element.name ?? ""),
|
||||||
|
fileSize: Number(element.fileSize ?? element.size ?? 0),
|
||||||
|
contentType: String(element.contentType ?? element.mimeType ?? ""),
|
||||||
|
downloadUrl: downloadHref,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a raw HAL WikiPage resource and its attachments into a WikiPageDetail.
|
||||||
|
*/
|
||||||
|
export function normalizeWikiPageDetail(
|
||||||
|
resource: HalResource,
|
||||||
|
attachments: WikiPageAttachment[] = []
|
||||||
|
): WikiPageDetail {
|
||||||
|
const links = (resource._links ?? {}) as Record<string, HalLink | undefined>;
|
||||||
|
const projectLink = links.project;
|
||||||
|
const embedded = (resource._embedded ?? {}) as Record<string, unknown>;
|
||||||
|
const embeddedProject = embedded.project as { id?: number; identifier?: string; name?: string } | undefined;
|
||||||
|
|
||||||
|
const projectId = extractIdFromHref(projectLink?.href) ?? embeddedProject?.id ?? 0;
|
||||||
|
const projectName = projectLink?.title ?? embeddedProject?.name;
|
||||||
|
const projectIdentifier =
|
||||||
|
embeddedProject?.identifier ?? (projectLink as { identifier?: string } | undefined)?.identifier;
|
||||||
|
|
||||||
|
const project: { id: number; identifier?: string; name?: string } = {
|
||||||
|
id: projectId,
|
||||||
|
...(projectIdentifier ? { identifier: projectIdentifier } : {}),
|
||||||
|
...(projectName ? { name: projectName } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = resource.id ?? extractIdFromHref(links.self?.href) ?? 0;
|
||||||
|
const title = String(resource.title ?? "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
project,
|
||||||
|
attachments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a WikiPageDetail into a token-efficient WikiPageSummary.
|
||||||
|
*/
|
||||||
|
export function toWikiPageSummary(detail: WikiPageDetail): WikiPageSummary {
|
||||||
|
return {
|
||||||
|
id: detail.id,
|
||||||
|
title: detail.title,
|
||||||
|
project: detail.project,
|
||||||
|
attachmentsCount: detail.attachments.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a raw HAL WikiPageLink resource into a clean WikiPageLink.
|
||||||
|
*/
|
||||||
|
export function normalizeWikiPageLink(resource: HalResource): WikiPageLink {
|
||||||
|
const links = (resource._links ?? {}) as Record<string, HalLink | undefined>;
|
||||||
|
const selfLink = links.self;
|
||||||
|
const wpLink = links.workPackage;
|
||||||
|
const wikiPageLink = links.wikiPage ?? links["wiki_page"];
|
||||||
|
|
||||||
|
const embedded = (resource._embedded ?? {}) as Record<string, unknown>;
|
||||||
|
const embeddedWp = embedded.workPackage as { id?: number; subject?: string; title?: string } | undefined;
|
||||||
|
|
||||||
|
const id = resource.id ?? extractIdFromHref(selfLink?.href) ?? 0;
|
||||||
|
const pageTitle =
|
||||||
|
(typeof resource.pageTitle === "string" ? resource.pageTitle : undefined) ??
|
||||||
|
(typeof resource.title === "string" ? resource.title : undefined) ??
|
||||||
|
wikiPageLink?.title;
|
||||||
|
|
||||||
|
const pageUrl =
|
||||||
|
(typeof resource.pageUrl === "string" ? resource.pageUrl : undefined) ??
|
||||||
|
(typeof resource.url === "string" ? resource.url : undefined) ??
|
||||||
|
wikiPageLink?.href;
|
||||||
|
|
||||||
|
const provider = typeof resource.provider === "string" ? resource.provider : undefined;
|
||||||
|
|
||||||
|
const wpId = extractIdFromHref(wpLink?.href) ?? embeddedWp?.id;
|
||||||
|
const wpTitle = wpLink?.title ?? embeddedWp?.title ?? embeddedWp?.subject;
|
||||||
|
|
||||||
|
const workPackage =
|
||||||
|
wpId !== undefined
|
||||||
|
? {
|
||||||
|
id: wpId,
|
||||||
|
...(wpTitle ? { title: wpTitle } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
...(pageTitle ? { pageTitle } : {}),
|
||||||
|
...(pageUrl ? { pageUrl } : {}),
|
||||||
|
...(provider ? { provider } : {}),
|
||||||
|
...(workPackage ? { workPackage } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a single wiki page by ID including its attachments.
|
||||||
|
*/
|
||||||
|
export async function getWikiPage(
|
||||||
|
id: number,
|
||||||
|
client?: OpenProjectClient
|
||||||
|
): Promise<WikiPageDetail> {
|
||||||
|
const opClient = resolveClient(client);
|
||||||
|
const pageResource = await opClient.get<HalResource>(`/api/v3/wiki_pages/${id}`);
|
||||||
|
|
||||||
|
let attachments: WikiPageAttachment[] = [];
|
||||||
|
try {
|
||||||
|
const attachmentsResponse = await opClient.get<HalCollection<HalResource>>(
|
||||||
|
`/api/v3/wiki_pages/${id}/attachments`
|
||||||
|
);
|
||||||
|
if (attachmentsResponse._embedded?.elements) {
|
||||||
|
attachments = attachmentsResponse._embedded.elements.map(normalizeWikiAttachment);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If attachments collection cannot be retrieved, default to empty list
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = normalizeWikiPageDetail(pageResource, attachments);
|
||||||
|
|
||||||
|
// Update in-memory cache for this client baseUrl if initialized
|
||||||
|
const cacheKey = opClient.baseUrl || "default";
|
||||||
|
let cacheMap = wikiPagesCache.get(cacheKey);
|
||||||
|
if (!cacheMap) {
|
||||||
|
cacheMap = new Map<number, WikiPageDetail>();
|
||||||
|
wikiPagesCache.set(cacheKey, cacheMap);
|
||||||
|
}
|
||||||
|
cacheMap.set(detail.id, detail);
|
||||||
|
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists wiki page links, optionally filtered by work package ID.
|
||||||
|
*/
|
||||||
|
export async function listWikiPageLinks(
|
||||||
|
params?: ListWikiPageLinksParams,
|
||||||
|
client?: OpenProjectClient
|
||||||
|
): Promise<PaginatedResult<WikiPageLink>> {
|
||||||
|
const opClient = resolveClient(client);
|
||||||
|
const path =
|
||||||
|
params?.workPackageId !== undefined
|
||||||
|
? `/api/v3/work_packages/${params.workPackageId}/wiki_page_links`
|
||||||
|
: `/api/v3/wiki_page_links`;
|
||||||
|
|
||||||
|
const query: Record<string, string | number | boolean | undefined> = {};
|
||||||
|
if (params?.offset !== undefined) query.offset = params.offset;
|
||||||
|
if (params?.pageSize !== undefined) query.pageSize = params.pageSize;
|
||||||
|
|
||||||
|
const response = await opClient.get<HalCollection<HalResource>>(path, query);
|
||||||
|
const items = (response._embedded?.elements ?? []).map(normalizeWikiPageLink);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: response.total ?? items.length,
|
||||||
|
count: response.count ?? items.length,
|
||||||
|
pageSize: response.pageSize ?? items.length,
|
||||||
|
offset: response.offset ?? 1,
|
||||||
|
elements: items,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches wiki pages using in-memory caching and smart discovery scanning.
|
||||||
|
*/
|
||||||
|
export async function searchWikiPages(
|
||||||
|
params?: SearchWikiPagesParams,
|
||||||
|
client?: OpenProjectClient
|
||||||
|
): Promise<WikiPageSummary[]> {
|
||||||
|
const opClient = resolveClient(client);
|
||||||
|
const cacheKey = opClient.baseUrl || "default";
|
||||||
|
|
||||||
|
let cacheMap = wikiPagesCache.get(cacheKey);
|
||||||
|
const needsDiscovery = !cacheMap || !discoveredBaseUrls.has(cacheKey) || params?.refreshCache;
|
||||||
|
|
||||||
|
if (!cacheMap) {
|
||||||
|
cacheMap = new Map<number, WikiPageDetail>();
|
||||||
|
wikiPagesCache.set(cacheKey, cacheMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsDiscovery) {
|
||||||
|
// 1. Check wiki_page_links to collect referenced wiki page IDs
|
||||||
|
const discoveredIds = new Set<number>();
|
||||||
|
try {
|
||||||
|
const linksResponse = await opClient.get<HalCollection<HalResource>>("/api/v3/wiki_page_links", {
|
||||||
|
pageSize: 100,
|
||||||
|
});
|
||||||
|
const elements = linksResponse._embedded?.elements ?? [];
|
||||||
|
for (const element of elements) {
|
||||||
|
const links = (element._links ?? {}) as Record<string, HalLink | undefined>;
|
||||||
|
const wikiPageLink = links.wikiPage ?? links["wiki_page"];
|
||||||
|
const idFromLink = extractIdFromHref(wikiPageLink?.href);
|
||||||
|
if (idFromLink !== undefined) {
|
||||||
|
discoveredIds.add(idFromLink);
|
||||||
|
}
|
||||||
|
if (element.pageUrl && typeof element.pageUrl === "string") {
|
||||||
|
const idFromUrl = extractIdFromHref(element.pageUrl);
|
||||||
|
if (idFromUrl !== undefined) {
|
||||||
|
discoveredIds.add(idFromUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors if wiki_page_links is not supported or accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sequential probing of IDs 1..MAX_PROBE_ID with consecutive 404 cutoff
|
||||||
|
const MAX_PROBE_ID = 50;
|
||||||
|
const CONSECUTIVE_404_CUTOFF = 5;
|
||||||
|
let consecutiveMisses = 0;
|
||||||
|
|
||||||
|
for (let id = 1; id <= MAX_PROBE_ID; id++) {
|
||||||
|
try {
|
||||||
|
const pageDetail = await getWikiPage(id, opClient);
|
||||||
|
cacheMap.set(id, pageDetail);
|
||||||
|
consecutiveMisses = 0;
|
||||||
|
} catch {
|
||||||
|
consecutiveMisses++;
|
||||||
|
if (consecutiveMisses >= CONSECUTIVE_404_CUTOFF) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also fetch any referenced wiki page IDs discovered from wiki_page_links that weren't probed
|
||||||
|
for (const id of discoveredIds) {
|
||||||
|
if (!cacheMap.has(id)) {
|
||||||
|
try {
|
||||||
|
const pageDetail = await getWikiPage(id, opClient);
|
||||||
|
cacheMap.set(id, pageDetail);
|
||||||
|
} catch {
|
||||||
|
// Ignore missing pages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredBaseUrls.add(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Resolve target project ID if provided
|
||||||
|
let targetProjectId: number | undefined;
|
||||||
|
if (params?.projectId !== undefined) {
|
||||||
|
targetProjectId = await resolveProjectId(params.projectId, opClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Filter cached pages
|
||||||
|
const allPages = Array.from(cacheMap.values());
|
||||||
|
const querySubstring = params?.query?.trim().toLowerCase();
|
||||||
|
|
||||||
|
let filtered = allPages.filter((page) => {
|
||||||
|
if (targetProjectId !== undefined && page.project.id !== targetProjectId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (querySubstring && !page.title.toLowerCase().includes(querySubstring)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Apply limit
|
||||||
|
if (params?.limit !== undefined && params.limit > 0) {
|
||||||
|
filtered = filtered.slice(0, params.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.map(toWikiPageSummary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { describe, expect, test, beforeEach } from "bun:test";
|
||||||
|
import { OpenProjectClient } 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user