diff --git a/packages/domain/src/fingerprint-discovery.test.ts b/packages/domain/src/fingerprint-discovery.test.ts index bf22317..23ae781 100644 --- a/packages/domain/src/fingerprint-discovery.test.ts +++ b/packages/domain/src/fingerprint-discovery.test.ts @@ -156,6 +156,73 @@ describe("discoverFingerprintMatches", () => { }); }); + it("finds nothing rather than failing when the root has no readable roster", async () => { + // Break caught: Blizzard 404s a character it holds no current profile for, + // which would discard an otherwise good Raider.IO snapshot and retry a root + // that can never be swept. + await expect( + discoverFingerprintMatches( + root, + { + async getGuildRoster() { + throw Object.assign(new Error("missing"), { kind: "not_found" }); + }, + async getAchievementFingerprint() { + throw new Error("unreachable"); + } + }, + options + ) + ).resolves.toEqual({ kind: "matched", requestsUsed: 1, characters: [] }); + }); + + it("finds nothing rather than failing when the root has no readable profile", async () => { + // Break caught: the same 404 on the root's own achievements would strand the + // run instead of publishing its Raider.IO result. + await expect( + discoverFingerprintMatches( + root, + gatewayFor([candidate(matchingKey)], {}), + options + ) + ).resolves.toEqual({ kind: "matched", requestsUsed: 2, characters: [] }); + }); + + it("skips a candidate with no readable profile and keeps sweeping", async () => { + // Break caught: a roster member whose achievements are unreadable is + // ordinary — the measured live sweep saw 23 of 393 — so treating one as an + // upstream failure would abandon every real sweep and publish nothing. + const outcome = await discoverFingerprintMatches( + root, + gatewayFor( + [ + candidate({ region: "eu", realm: "silvermoon", name: "a-missing" }), + candidate(matchingKey) + ], + { + [keyId(root)]: fingerprint(200), + [keyId(matchingKey)]: fingerprint(200) + } + ), + { ...options, requestCap: 4, isSuppressed: async () => false } + ); + + expect(outcome).toEqual({ + kind: "matched", + requestsUsed: 4, + characters: [ + { + key: matchingKey, + displayName: "matching", + className: "Mage", + level: 80, + raiderIoUrl: "https://raider.io/characters/eu/silvermoon/matching", + source: "fingerprint" + } + ] + }); + }); + it("rechecks privacy immediately before admitting a matched candidate", async () => { // Break caught: a privacy-hidden designation that lands while the candidate // fingerprint is being fetched could still be retained in the result. diff --git a/packages/domain/src/fingerprint-discovery.ts b/packages/domain/src/fingerprint-discovery.ts index 58eef1b..b8273c1 100644 --- a/packages/domain/src/fingerprint-discovery.ts +++ b/packages/domain/src/fingerprint-discovery.ts @@ -50,6 +50,15 @@ export type DiscoverFingerprintMatchesOptions = { signal?: AbortSignal; }; +function isNotFound(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "kind" in error && + error.kind === "not_found" + ); +} + function isCharacterKey(value: unknown): value is CharacterKey { if (typeof value !== "object" || value === null) return false; @@ -211,17 +220,37 @@ export async function discoverFingerprintMatches( } try { - const roster = await request(() => - gateway.getGuildRoster(root, options.signal) - ); + // Blizzard holds no current profile for plenty of characters Raider.IO + // knows, and answers 404. That makes this root unsweepable rather than the + // upstream broken, so report an empty sweep and let its Raider.IO snapshot + // publish instead of stranding the run on a retry that cannot succeed. + let roster: readonly FingerprintCandidate[] | typeof budgetExhausted; + try { + roster = await request(() => + gateway.getGuildRoster(root, options.signal) + ); + } catch (error) { + if (isNotFound(error)) { + return { kind: "matched", characters: [], requestsUsed }; + } + throw error; + } if (roster === budgetExhausted) { return { kind: "capped", characters: [], requestsUsed }; } if (!isCandidateList(roster)) throw { kind: "schema_drift" }; - const rootFingerprint = await request(() => - gateway.getAchievementFingerprint(root, options.signal) - ); + let rootFingerprint: ReadonlyMap | typeof budgetExhausted; + try { + rootFingerprint = await request(() => + gateway.getAchievementFingerprint(root, options.signal) + ); + } catch (error) { + if (isNotFound(error)) { + return { kind: "matched", characters: [], requestsUsed }; + } + throw error; + } if (rootFingerprint === budgetExhausted) { return { kind: "capped", characters: [], requestsUsed }; } @@ -249,9 +278,19 @@ export async function discoverFingerprintMatches( throwIfAborted(); if (isPrivacyHidden) continue; - const candidateFingerprint = await request(() => - gateway.getAchievementFingerprint(candidate.key, options.signal) - ); + // A roster member with no readable achievement profile is ordinary, not an + // upstream fault: the measured live sweep saw 23 of 393 candidates return + // one. Skip the candidate and keep the request it already consumed. + let candidateFingerprint: + ReadonlyMap | typeof budgetExhausted; + try { + candidateFingerprint = await request(() => + gateway.getAchievementFingerprint(candidate.key, options.signal) + ); + } catch (error) { + if (isNotFound(error)) continue; + throw error; + } if (candidateFingerprint === budgetExhausted) break; if (!isFingerprint(candidateFingerprint)) throw { kind: "schema_drift" };