fix: discriminate discovery probe errors and evict cache on refresh

This commit is contained in:
Lucas Winther
2026-09-07 02:34:35 +02:00
parent 1ce61506b2
commit 6003925bcf
2 changed files with 136 additions and 7 deletions
+39 -6
View File
@@ -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 {
consecutiveMisses++;
} 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
View File
@@ -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);
});
});