diff --git a/src/services/work-packages.ts b/src/services/work-packages.ts index 48c9605..d97a6c4 100644 --- a/src/services/work-packages.ts +++ b/src/services/work-packages.ts @@ -18,7 +18,7 @@ import type { WorkPackageSummary, } from "../client/types.ts"; import { resolveClient, resolveProjectId } from "./helper.ts"; -import { searchPipeline } from "../search/pipeline.ts"; +import { isFatalSearchError, searchPipeline } from "../search/pipeline.ts"; import type { FieldSpec, MatchMode } from "../search/rank.ts"; export interface ListWorkPackagesParams extends WorkPackageFilterParams { @@ -147,22 +147,34 @@ export async function searchWorkPackages( assigneeId: options?.assigneeId, }; + // A non-fatal failure on one leg (e.g. a transient 500) must not kill the + // whole search: the other leg's candidates are still worth ranking. A + // fatal failure (401/403/429) must propagate rather than degrade into "0 + // candidates" — an expired token must not be reported to the model as "no + // results found". + const listCandidates = async ( + params: ListWorkPackagesParams + ): Promise<{ items: WorkPackageSummary[] }> => { + try { + return await listWorkPackages(params, opClient); + } catch (error: unknown) { + if (isFatalSearchError(error)) { + throw error; + } + return { items: [] }; + } + }; + const fetchCandidates = async (): Promise => { const [precise, broad] = await Promise.all([ - listWorkPackages( - { ...sharedFilters, subject: query, pageSize: MAX_WORK_PACKAGE_CANDIDATES }, - opClient - ).catch(() => ({ items: [] as WorkPackageSummary[] })), + listCandidates({ ...sharedFilters, subject: query, pageSize: MAX_WORK_PACKAGE_CANDIDATES }), matchMode === "exact" ? Promise.resolve({ items: [] as WorkPackageSummary[] }) - : listWorkPackages( - { - ...sharedFilters, - pageSize: MAX_WORK_PACKAGE_CANDIDATES, - sortBy: '[["updatedAt","desc"]]', - }, - opClient - ).catch(() => ({ items: [] as WorkPackageSummary[] })), + : listCandidates({ + ...sharedFilters, + pageSize: MAX_WORK_PACKAGE_CANDIDATES, + sortBy: '[["updatedAt","desc"]]', + }), ]); const byId = new Map(); diff --git a/tests/services.test.ts b/tests/services.test.ts index 9a2c162..978b29a 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -14,7 +14,11 @@ import { listUsers, } from "../src/services/metadata.ts"; import * as domainServices from "../src/services/index.ts"; -import { OpenProjectClient } from "../src/client/api-client.ts"; +import { + OpenProjectClient, + OpenProjectAuthenticationError, + OpenProjectError, +} from "../src/client/api-client.ts"; import { runWithContext, type RequestContext } from "../src/context.ts"; describe("Domain Services Helper", () => { @@ -884,5 +888,56 @@ describe("Work Package Fuzzy Search", () => { ); expect(result.elements).toHaveLength(0); }); + + test("propagates an auth failure from candidate fetch instead of reporting no results", async () => { + const client = { + get: async () => { + throw new OpenProjectAuthenticationError(); + }, + } as unknown as OpenProjectClient; + + await expect(searchWorkPackages("anything", {}, client)).rejects.toThrow( + OpenProjectAuthenticationError + ); + }); + + test("a non-fatal failure on one candidate leg still returns results from the other", async () => { + let calls = 0; + const client = { + get: async (path: string) => { + calls++; + // Fail the precise (subject-filtered) leg non-fatally; the broad + // leg (no subject filter) should still supply candidates. + if (calls === 1) { + throw new OpenProjectError("boom", { statusCode: 500 }); + } + return { + _type: "Collection", + total: 1, + count: 1, + pageSize: 100, + offset: 1, + _embedded: { + elements: [ + { + _type: "WorkPackage", + id: 1, + subject: "Fix login redirect", + _links: { + type: { title: "Bug" }, + status: { title: "New" }, + project: { href: "/api/v3/projects/1", title: "Demo project" }, + }, + updatedAt: "2026-09-10T00:00:00Z", + }, + ], + }, + }; + }, + } as unknown as OpenProjectClient; + + const result = await searchWorkPackages("login", {}, client); + expect(result.elements[0]!.workPackage.id).toBe(1); + }); });