fix: discriminate discovery probe errors and evict cache on refresh
This commit is contained in:
+39
-6
@@ -3,7 +3,10 @@
|
|||||||
* Provides functions to inspect, search, and list OpenProject wiki pages and wiki page links.
|
* 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 { extractIdFromHref } from "../client/hal-parser.ts";
|
||||||
import type { HalCollection, HalLink, HalResource } from "../client/types.ts";
|
import type { HalCollection, HalLink, HalResource } from "../client/types.ts";
|
||||||
import { resolveClient, resolveProjectId } from "./helper.ts";
|
import { resolveClient, resolveProjectId } from "./helper.ts";
|
||||||
@@ -261,6 +264,10 @@ export async function searchWikiPages(
|
|||||||
let cacheMap = wikiPagesCache.get(cacheKey);
|
let cacheMap = wikiPagesCache.get(cacheKey);
|
||||||
const needsDiscovery = !cacheMap || !discoveredBaseUrls.has(cacheKey) || params?.refreshCache;
|
const needsDiscovery = !cacheMap || !discoveredBaseUrls.has(cacheKey) || params?.refreshCache;
|
||||||
|
|
||||||
|
if (params?.refreshCache && cacheMap) {
|
||||||
|
cacheMap.clear();
|
||||||
|
}
|
||||||
|
|
||||||
if (!cacheMap) {
|
if (!cacheMap) {
|
||||||
cacheMap = new Map<number, WikiPageDetail>();
|
cacheMap = new Map<number, WikiPageDetail>();
|
||||||
wikiPagesCache.set(cacheKey, cacheMap);
|
wikiPagesCache.set(cacheKey, cacheMap);
|
||||||
@@ -288,8 +295,16 @@ export async function searchWikiPages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
// Ignore errors if wiki_page_links is not supported or accessible
|
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
|
// 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);
|
const pageDetail = await getWikiPage(id, opClient);
|
||||||
cacheMap.set(id, pageDetail);
|
cacheMap.set(id, pageDetail);
|
||||||
consecutiveMisses = 0;
|
consecutiveMisses = 0;
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
consecutiveMisses++;
|
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) {
|
if (consecutiveMisses >= CONSECUTIVE_404_CUTOFF) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -316,7 +341,15 @@ export async function searchWikiPages(
|
|||||||
try {
|
try {
|
||||||
const pageDetail = await getWikiPage(id, opClient);
|
const pageDetail = await getWikiPage(id, opClient);
|
||||||
cacheMap.set(id, pageDetail);
|
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
|
// Ignore missing pages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-1
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, test, beforeEach } from "bun:test";
|
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 { runWithContext, type RequestContext } from "../src/context.ts";
|
||||||
import {
|
import {
|
||||||
clearWikiCache,
|
clearWikiCache,
|
||||||
@@ -382,4 +385,97 @@ describe("Wikis Service", () => {
|
|||||||
expect(searchRes[0]!.title).toBe("Context Wiki");
|
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