fix: discriminate discovery probe errors and evict cache on refresh
This commit is contained in:
+38
-5
@@ -3,7 +3,10 @@
|
||||
* Provides functions to inspect, search, and list OpenProject wiki pages and wiki page links.
|
||||
*/
|
||||
|
||||
import type { OpenProjectClient } from "../client/api-client.ts";
|
||||
import {
|
||||
OpenProjectAuthenticationError,
|
||||
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";
|
||||
@@ -261,6 +264,10 @@ export async function searchWikiPages(
|
||||
let cacheMap = wikiPagesCache.get(cacheKey);
|
||||
const needsDiscovery = !cacheMap || !discoveredBaseUrls.has(cacheKey) || params?.refreshCache;
|
||||
|
||||
if (params?.refreshCache && cacheMap) {
|
||||
cacheMap.clear();
|
||||
}
|
||||
|
||||
if (!cacheMap) {
|
||||
cacheMap = new Map<number, WikiPageDetail>();
|
||||
wikiPagesCache.set(cacheKey, cacheMap);
|
||||
@@ -288,8 +295,16 @@ export async function searchWikiPages(
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors if wiki_page_links is not supported or accessible
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof OpenProjectAuthenticationError ||
|
||||
(err as { statusCode?: number })?.statusCode === 401 ||
|
||||
(err as { statusCode?: number })?.statusCode === 403 ||
|
||||
(err as { statusCode?: number })?.statusCode === 429
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
// Ignore errors if wiki_page_links is not supported or accessible (e.g. 404)
|
||||
}
|
||||
|
||||
// 2. Sequential probing of IDs 1..MAX_PROBE_ID with consecutive 404 cutoff
|
||||
@@ -302,8 +317,18 @@ export async function searchWikiPages(
|
||||
const pageDetail = await getWikiPage(id, opClient);
|
||||
cacheMap.set(id, pageDetail);
|
||||
consecutiveMisses = 0;
|
||||
} catch {
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof OpenProjectAuthenticationError ||
|
||||
(err as { statusCode?: number })?.statusCode === 401 ||
|
||||
(err as { statusCode?: number })?.statusCode === 403 ||
|
||||
(err as { statusCode?: number })?.statusCode === 429
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
if ((err as { statusCode?: number })?.statusCode === 404) {
|
||||
consecutiveMisses++;
|
||||
}
|
||||
if (consecutiveMisses >= CONSECUTIVE_404_CUTOFF) {
|
||||
break;
|
||||
}
|
||||
@@ -316,7 +341,15 @@ export async function searchWikiPages(
|
||||
try {
|
||||
const pageDetail = await getWikiPage(id, opClient);
|
||||
cacheMap.set(id, pageDetail);
|
||||
} catch {
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof OpenProjectAuthenticationError ||
|
||||
(err as { statusCode?: number })?.statusCode === 401 ||
|
||||
(err as { statusCode?: number })?.statusCode === 403 ||
|
||||
(err as { statusCode?: number })?.statusCode === 429
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
// Ignore missing pages
|
||||
}
|
||||
}
|
||||
|
||||
+97
-1
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, test, beforeEach } from "bun:test";
|
||||
import { OpenProjectClient } from "../src/client/api-client.ts";
|
||||
import {
|
||||
OpenProjectAuthenticationError,
|
||||
OpenProjectClient,
|
||||
} from "../src/client/api-client.ts";
|
||||
import { runWithContext, type RequestContext } from "../src/context.ts";
|
||||
import {
|
||||
clearWikiCache,
|
||||
@@ -382,4 +385,97 @@ describe("Wikis Service", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user