Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/domain/src/fingerprint-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 48 additions & 9 deletions packages/domain/src/fingerprint-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<number, number> | 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 };
}
Expand Down Expand Up @@ -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<number, number> | 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" };

Expand Down
Loading