/** * geocoder.test.ts — Tests for all geocoder.ts behavioral contracts * * Tests mock global fetch via vi.fn() and use fake timers for rate-limit testing. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { geocodePlaces } from "../src/geocoder"; import type { Place } from "../src/parser"; // ─── Helpers ────────────────────────────────────────────────────────── /** Create a Place with sensible defaults */ function makePlace( name: string, overrides: Partial = {} ): Place { return { name, fields: {}, notes: [], startLine: 0, endLine: 0, ...overrides, }; } /** Build a successful Nominatim JSON response */ function nominatimOk(lat: number, lng: number): Response { return new Response(JSON.stringify([{ lat: String(lat), lon: String(lng) }]), { status: 200, headers: { "Content-Type": "application/json" }, }); } /** Build an empty Nominatim response (no results) */ function nominatimEmpty(): Response { return new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" }, }); } // ─── Mock Setup ─────────────────────────────────────────────────────── let mockFetch: ReturnType; beforeEach(() => { mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); // ─── Contract 13: Empty array short-circuit ─────────────────────────── describe("Contract 13: empty array short-circuit", () => { it("returns [] immediately for empty input, no API calls", async () => { const result = await geocodePlaces([]); expect(result).toEqual([]); expect(mockFetch).not.toHaveBeenCalled(); }); }); // ─── Contract 1: Skip places with existing coordinates ──────────────── describe("Contract 1: skip places with existing coordinates", () => { it("returns places with lat/lng unchanged, no API calls", async () => { const places = [ makePlace("Sagrada Familia", { lat: 41.4036, lng: 2.1744 }), ]; const result = await geocodePlaces(places); expect(result).toHaveLength(1); expect(result[0].lat).toBe(41.4036); expect(result[0].lng).toBe(2.1744); expect(mockFetch).not.toHaveBeenCalled(); }); it("only geocodes places missing coordinates", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); const places = [ makePlace("Sagrada Familia", { lat: 41.4036, lng: 2.1744 }), makePlace("The Louvre"), ]; const result = await geocodePlaces(places); expect(result).toHaveLength(2); expect(result[0].lat).toBe(41.4036); // unchanged expect(result[1].lat).toBe(48.8606); // geocoded expect(mockFetch).toHaveBeenCalledTimes(1); }); it("treats lat: 0, lng: 0 as valid existing coordinates (not falsy)", async () => { const places = [ makePlace("Null Island", { lat: 0, lng: 0 }), ]; const result = await geocodePlaces(places); expect(result[0].lat).toBe(0); expect(result[0].lng).toBe(0); expect(mockFetch).not.toHaveBeenCalled(); }); it("geocodes places with only lat set (half-populated)", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); const places = [makePlace("Half Place", { lat: 41.4036 })]; const result = await geocodePlaces(places); // Only lat was set, lng was undefined — should geocode expect(result[0].lat).toBe(41.4036); expect(result[0].lng).toBe(2.1744); expect(mockFetch).toHaveBeenCalledTimes(1); }); it("geocodes places with only lng set (half-populated)", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); const places = [makePlace("Half Place", { lng: 2.1744 })]; const result = await geocodePlaces(places); expect(result[0].lat).toBe(41.4036); expect(result[0].lng).toBe(2.1744); expect(mockFetch).toHaveBeenCalledTimes(1); }); }); // ─── Contract 2 & 3: Nominatim URL and query string ────────────────── describe("Contract 2 & 3: Nominatim URL and query params", () => { it("queries Nominatim with correct URL, format, limit, and encoded name", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); await geocodePlaces([makePlace("Sagrada Familia")]); expect(mockFetch).toHaveBeenCalledTimes(1); const [url, options] = mockFetch.mock.calls[0]; const parsedUrl = new URL(url); expect(parsedUrl.origin + parsedUrl.pathname).toBe( "https://nominatim.openstreetmap.org/search" ); expect(parsedUrl.searchParams.get("format")).toBe("json"); expect(parsedUrl.searchParams.get("limit")).toBe("1"); expect(parsedUrl.searchParams.get("q")).toBe("Sagrada Familia"); }); it("properly encodes special characters and unicode in place names", async () => { mockFetch .mockResolvedValueOnce(nominatimOk(48.137, 11.575)) .mockResolvedValueOnce(nominatimOk(48.856, 2.352)) .mockResolvedValueOnce(nominatimOk(35.659, 139.700)); vi.useFakeTimers(); const places = [ makePlace("München"), makePlace("Café & Bar"), makePlace("東京タワー"), ]; const promise = geocodePlaces(places); await vi.runAllTimersAsync(); await promise; // Verify each name was correctly passed through URL encoding const call0Url = new URL(mockFetch.mock.calls[0][0]); expect(call0Url.searchParams.get("q")).toBe("München"); const call1Url = new URL(mockFetch.mock.calls[1][0]); expect(call1Url.searchParams.get("q")).toBe("Café & Bar"); const call2Url = new URL(mockFetch.mock.calls[2][0]); expect(call2Url.searchParams.get("q")).toBe("東京タワー"); vi.useRealTimers(); }); }); // ─── Contract 6: User-Agent header ──────────────────────────────────── describe("Contract 6: User-Agent header", () => { it("sets User-Agent to ObsidianMapViewer/1.0", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); await geocodePlaces([makePlace("Sagrada Familia")]); const [, options] = mockFetch.mock.calls[0]; expect(options.headers["User-Agent"]).toBe("ObsidianMapViewer/1.0"); }); }); // ─── Contract 4: Rate limiting ──────────────────────────────────────── describe("Contract 4: rate limiting (1100ms between requests)", () => { it("waits at least 1100ms between sequential API calls", async () => { vi.useFakeTimers(); mockFetch .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); const places = [makePlace("Place A"), makePlace("Place B")]; const promise = geocodePlaces(places); // First fetch fires immediately await vi.advanceTimersByTimeAsync(0); expect(mockFetch).toHaveBeenCalledTimes(1); // At 1099ms, second fetch should not have fired yet await vi.advanceTimersByTimeAsync(1099); expect(mockFetch).toHaveBeenCalledTimes(1); // At 1100ms, second fetch should fire await vi.advanceTimersByTimeAsync(1); expect(mockFetch).toHaveBeenCalledTimes(2); // Let the promise resolve await vi.advanceTimersByTimeAsync(0); await promise; vi.useRealTimers(); }); it("requests are fully sequential — waits for response before delaying", async () => { vi.useFakeTimers(); let resolveFirst: (value: Response) => void; const firstFetchPromise = new Promise((resolve) => { resolveFirst = resolve; }); mockFetch .mockReturnValueOnce(firstFetchPromise) .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); const places = [makePlace("Place A"), makePlace("Place B")]; const promise = geocodePlaces(places); // First fetch fires await vi.advanceTimersByTimeAsync(0); expect(mockFetch).toHaveBeenCalledTimes(1); // Even after 2000ms, second fetch shouldn't fire because first hasn't resolved await vi.advanceTimersByTimeAsync(2000); expect(mockFetch).toHaveBeenCalledTimes(1); // Resolve first fetch resolveFirst!(nominatimOk(41.4036, 2.1744)); await vi.advanceTimersByTimeAsync(0); // Still need to wait 1100ms after response expect(mockFetch).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1100); expect(mockFetch).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(0); await promise; vi.useRealTimers(); }); }); // ─── Contract 5: Deduplication ──────────────────────────────────────── describe("Contract 5: deduplication (case-insensitive, trimmed)", () => { it("makes only one API call for duplicate names", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); const places = [ makePlace("Sagrada Familia"), makePlace("sagrada familia"), ]; const result = await geocodePlaces(places); expect(mockFetch).toHaveBeenCalledTimes(1); // Both places get the same result expect(result[0].lat).toBe(41.4036); expect(result[1].lat).toBe(41.4036); }); it("uses the first-encountered variant for the API call", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); const places = [ makePlace("Sagrada Familia"), makePlace("SAGRADA FAMILIA"), ]; await geocodePlaces(places); const [url] = mockFetch.mock.calls[0]; const parsedUrl = new URL(url); expect(parsedUrl.searchParams.get("q")).toBe("Sagrada Familia"); }); it("trims names for dedup comparison", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); const places = [ makePlace(" Sagrada Familia "), makePlace("Sagrada Familia"), ]; const result = await geocodePlaces(places); expect(mockFetch).toHaveBeenCalledTimes(1); expect(result[0].lat).toBe(41.4036); expect(result[1].lat).toBe(41.4036); }); }); // ─── Contract 7: onProgress callback ───────────────────────────────── describe("Contract 7: onProgress callback", () => { it("calls onProgress after each unique geocode completes (success)", async () => { vi.useFakeTimers(); mockFetch .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); const onProgress = vi.fn(); const places = [makePlace("Place A"), makePlace("Place B")]; const promise = geocodePlaces(places, { onProgress }); await vi.runAllTimersAsync(); await promise; expect(onProgress).toHaveBeenCalledTimes(2); expect(onProgress).toHaveBeenCalledWith( expect.objectContaining({ name: "Place A" }), { lat: 41.4036, lng: 2.1744 } ); expect(onProgress).toHaveBeenCalledWith( expect.objectContaining({ name: "Place B" }), { lat: 48.8606, lng: 2.3376 } ); vi.useRealTimers(); }); it("calls onProgress with null for failed geocodes", async () => { mockFetch.mockRejectedValueOnce(new Error("Network error")); const onProgress = vi.fn(); await geocodePlaces([makePlace("Bad Place")], { onProgress }); expect(onProgress).toHaveBeenCalledTimes(1); expect(onProgress).toHaveBeenCalledWith( expect.objectContaining({ name: "Bad Place" }), null ); }); it("calls onProgress with null for empty Nominatim results", async () => { mockFetch.mockResolvedValueOnce(nominatimEmpty()); const onProgress = vi.fn(); await geocodePlaces([makePlace("Nonexistent Place")], { onProgress }); expect(onProgress).toHaveBeenCalledTimes(1); expect(onProgress).toHaveBeenCalledWith( expect.objectContaining({ name: "Nonexistent Place" }), null ); }); it("calls onProgress once per unique geocode, not per duplicate place", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); const onProgress = vi.fn(); const places = [ makePlace("Sagrada Familia"), makePlace("sagrada familia"), ]; await geocodePlaces(places, { onProgress }); // One API call = one onProgress call, reported with the first place in the group. // Duplicate places still get their lat/lng set, but onProgress fires once per unique query. expect(onProgress).toHaveBeenCalledTimes(1); expect(onProgress).toHaveBeenCalledWith( expect.objectContaining({ name: "Sagrada Familia" }), { lat: 41.4036, lng: 2.1744 } ); }); }); // ─── Contract 8: Network failure resilience ─────────────────────────── describe("Contract 8: network failures don't abort batch", () => { it("skips failed place and continues with remaining", async () => { vi.useFakeTimers(); mockFetch .mockRejectedValueOnce(new Error("Network error")) .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); const places = [makePlace("Bad Place"), makePlace("Good Place")]; const promise = geocodePlaces(places); await vi.runAllTimersAsync(); const result = await promise; expect(result).toHaveLength(2); expect(result[0].lat).toBeUndefined(); expect(result[0].lng).toBeUndefined(); expect(result[1].lat).toBe(48.8606); expect(result[1].lng).toBe(2.3376); vi.useRealTimers(); }); it("keeps lat/lng undefined for places with no Nominatim results", async () => { mockFetch.mockResolvedValueOnce(nominatimEmpty()); const result = await geocodePlaces([makePlace("Nonexistent")]); expect(result[0].lat).toBeUndefined(); expect(result[0].lng).toBeUndefined(); }); it("handles non-200 HTTP responses gracefully (e.g., 429 rate limit)", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ error: "Rate limit exceeded" }), { status: 429, }) ); const result = await geocodePlaces([makePlace("Rate Limited")]); expect(result[0].lat).toBeUndefined(); expect(result[0].lng).toBeUndefined(); }); it("rejects non-200 responses even if body contains valid lat/lon JSON", async () => { // A proxy or CDN could return valid-looking JSON with a non-200 status mockFetch.mockResolvedValueOnce( new Response( JSON.stringify([{ lat: "99.999", lon: "99.999" }]), { status: 500 } ) ); const result = await geocodePlaces([makePlace("Server Error Place")]); expect(result[0].lat).toBeUndefined(); expect(result[0].lng).toBeUndefined(); }); it("handles invalid JSON response body gracefully", async () => { mockFetch.mockResolvedValueOnce( new Response("Server Error", { status: 200, headers: { "Content-Type": "text/html" }, }) ); const result = await geocodePlaces([makePlace("Bad Response")]); expect(result[0].lat).toBeUndefined(); expect(result[0].lng).toBeUndefined(); }); it("rejects out-of-range coordinates from Nominatim", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify([{ lat: "999", lon: "999" }]), { status: 200, headers: { "Content-Type": "application/json" }, }) ); const result = await geocodePlaces([makePlace("Bad Coords")]); expect(result[0].lat).toBeUndefined(); expect(result[0].lng).toBeUndefined(); }); }); // ─── Contract 9: Return full array with geocoded lat/lng ────────────── describe("Contract 9: returns full array with geocoded results", () => { it("returns all places with successfully geocoded lat/lng set", async () => { vi.useFakeTimers(); mockFetch .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); const places = [makePlace("Place A"), makePlace("Place B")]; const promise = geocodePlaces(places); await vi.runAllTimersAsync(); const result = await promise; expect(result).toHaveLength(2); expect(result[0]).toEqual( expect.objectContaining({ name: "Place A", lat: 41.4036, lng: 2.1744 }) ); expect(result[1]).toEqual( expect.objectContaining({ name: "Place B", lat: 48.8606, lng: 2.3376 }) ); vi.useRealTimers(); }); it("preserves all original place properties", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); const places = [ makePlace("Sagrada Familia", { url: "https://example.com", fields: { category: "Architecture" }, notes: ["Amazing"], startLine: 5, endLine: 8, }), ]; const result = await geocodePlaces(places); expect(result[0].url).toBe("https://example.com"); expect(result[0].fields).toEqual({ category: "Architecture" }); expect(result[0].notes).toEqual(["Amazing"]); expect(result[0].startLine).toBe(5); expect(result[0].endLine).toBe(8); expect(result[0].lat).toBe(41.4036); expect(result[0].lng).toBe(2.1744); }); it("handles mixed array: already-geocoded, needs-geocoding, and duplicates", async () => { vi.useFakeTimers(); // Only 2 unique names need geocoding: "The Louvre" and "Blue Bottle Coffee" // "Sagrada Familia" already has coords, "the louvre" is a duplicate mockFetch .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)) // The Louvre .mockResolvedValueOnce(nominatimOk(35.659, 139.700)); // Blue Bottle Coffee const places = [ makePlace("Sagrada Familia", { lat: 41.4036, lng: 2.1744 }), makePlace("The Louvre"), makePlace("the louvre"), // duplicate, case-insensitive makePlace("Blue Bottle Coffee"), ]; const promise = geocodePlaces(places); await vi.runAllTimersAsync(); const result = await promise; expect(result).toHaveLength(4); // Already geocoded — unchanged expect(result[0].lat).toBe(41.4036); expect(result[0].lng).toBe(2.1744); // Geocoded expect(result[1].lat).toBe(48.8606); expect(result[1].lng).toBe(2.3376); // Duplicate — same result as The Louvre expect(result[2].lat).toBe(48.8606); expect(result[2].lng).toBe(2.3376); // Geocoded expect(result[3].lat).toBe(35.659); expect(result[3].lng).toBe(139.700); // Only 2 API calls (skip already-geocoded, dedup duplicate) expect(mockFetch).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); }); // ─── Contract 10: 10-second fetch timeout ───────────────────────────── describe("Contract 10: 10-second fetch timeout", () => { it("aborts fetch after 10 seconds and treats as network failure", async () => { vi.useFakeTimers(); // A fetch that never resolves mockFetch.mockImplementationOnce( (_url: string, options: { signal: AbortSignal }) => { return new Promise((resolve, reject) => { options.signal.addEventListener("abort", () => { reject(new DOMException("The operation was aborted.", "AbortError")); }); }); } ); const places = [makePlace("Slow Place")]; const promise = geocodePlaces(places); // Advance past the 10s timeout await vi.advanceTimersByTimeAsync(10_000); const result = await promise; expect(result[0].lat).toBeUndefined(); expect(result[0].lng).toBeUndefined(); vi.useRealTimers(); }); it("fetch timeout is treated as network failure (place skipped, batch continues)", async () => { vi.useFakeTimers(); // First fetch times out, second succeeds mockFetch .mockImplementationOnce( (_url: string, options: { signal: AbortSignal }) => { return new Promise((resolve, reject) => { options.signal.addEventListener("abort", () => { reject( new DOMException("The operation was aborted.", "AbortError") ); }); }); } ) .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); const places = [makePlace("Slow Place"), makePlace("Fast Place")]; const promise = geocodePlaces(places); // Advance past timeout + rate limit await vi.advanceTimersByTimeAsync(10_000 + 1100); const result = await promise; expect(result[0].lat).toBeUndefined(); expect(result[1].lat).toBe(48.8606); vi.useRealTimers(); }); }); // ─── Contract 11: External AbortSignal cancellation ─────────────────── describe("Contract 11: external AbortSignal cancellation", () => { it("stops processing when signal fires, returns partial results", async () => { const controller = new AbortController(); mockFetch.mockImplementation(() => { // After first fetch, abort the signal controller.abort(); return Promise.resolve(nominatimOk(41.4036, 2.1744)); }); const places = [makePlace("Place A"), makePlace("Place B")]; const result = await geocodePlaces(places, {}, controller.signal); // Place A should be geocoded, Place B should not (signal was aborted after first fetch) expect(result[0].lat).toBe(41.4036); expect(result[1].lat).toBeUndefined(); expect(mockFetch).toHaveBeenCalledTimes(1); }); it("returns results obtained so far when signal is already aborted", async () => { const controller = new AbortController(); controller.abort(); const places = [makePlace("Place A")]; const result = await geocodePlaces(places, {}, controller.signal); expect(result[0].lat).toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); }); it("aborts in-flight fetch requests when signal fires", async () => { vi.useFakeTimers(); const controller = new AbortController(); let fetchSignal: AbortSignal | undefined; mockFetch.mockImplementationOnce( (_url: string, options: { signal: AbortSignal }) => { fetchSignal = options.signal; return new Promise((resolve, reject) => { options.signal.addEventListener("abort", () => { reject( new DOMException("The operation was aborted.", "AbortError") ); }); }); } ); const places = [makePlace("Place A")]; const promise = geocodePlaces(places, {}, controller.signal); // Let fetch start await vi.advanceTimersByTimeAsync(0); expect(mockFetch).toHaveBeenCalledTimes(1); // Fire the external signal controller.abort(); await vi.advanceTimersByTimeAsync(0); const result = await promise; expect(result[0].lat).toBeUndefined(); expect(fetchSignal!.aborted).toBe(true); vi.useRealTimers(); }); it("cancels immediately during rate-limit delay, does not wait full 1100ms", async () => { vi.useFakeTimers(); const controller = new AbortController(); mockFetch .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); const places = [makePlace("Place A"), makePlace("Place B")]; const promise = geocodePlaces(places, {}, controller.signal); // Let first fetch complete await vi.advanceTimersByTimeAsync(0); expect(mockFetch).toHaveBeenCalledTimes(1); // We're now in the 1100ms rate-limit delay. Abort at 500ms. await vi.advanceTimersByTimeAsync(500); controller.abort(); await vi.advanceTimersByTimeAsync(0); const result = await promise; // Place A was geocoded, Place B was not (aborted during delay) expect(result[0].lat).toBe(41.4036); expect(result[1].lat).toBeUndefined(); // Second fetch never fires expect(mockFetch).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); }); // ─── Contract 12: Error reporting ───────────────────────────────────── describe("Contract 12: error reporting", () => { it("logs console.warn with [MapViewer] prefix for failures", async () => { mockFetch.mockRejectedValueOnce(new Error("fail")); await geocodePlaces([makePlace("Bad Place")]); expect(console.warn).toHaveBeenCalled(); const warnCall = (console.warn as ReturnType).mock.calls[0]; expect(warnCall[0]).toContain("[MapViewer]"); }); it("calls onNotice after 3 consecutive failures", async () => { vi.useFakeTimers(); mockFetch .mockRejectedValueOnce(new Error("fail 1")) .mockRejectedValueOnce(new Error("fail 2")) .mockRejectedValueOnce(new Error("fail 3")); const onNotice = vi.fn(); const places = [ makePlace("Bad 1"), makePlace("Bad 2"), makePlace("Bad 3"), ]; const promise = geocodePlaces(places, { onNotice }); await vi.runAllTimersAsync(); await promise; expect(onNotice).toHaveBeenCalledWith( "Map Viewer: Geocoding issues — check your network connection" ); vi.useRealTimers(); }); it("calls onNotice exactly once even with more than 3 consecutive failures", async () => { vi.useFakeTimers(); mockFetch .mockRejectedValueOnce(new Error("fail 1")) .mockRejectedValueOnce(new Error("fail 2")) .mockRejectedValueOnce(new Error("fail 3")) .mockRejectedValueOnce(new Error("fail 4")) .mockRejectedValueOnce(new Error("fail 5")); const onNotice = vi.fn(); const places = [ makePlace("Bad 1"), makePlace("Bad 2"), makePlace("Bad 3"), makePlace("Bad 4"), makePlace("Bad 5"), ]; const promise = geocodePlaces(places, { onNotice }); await vi.runAllTimersAsync(); await promise; expect(onNotice).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); it("does NOT call onNotice for fewer than 3 consecutive failures", async () => { vi.useFakeTimers(); mockFetch .mockRejectedValueOnce(new Error("fail 1")) .mockRejectedValueOnce(new Error("fail 2")); const onNotice = vi.fn(); const places = [makePlace("Bad 1"), makePlace("Bad 2")]; const promise = geocodePlaces(places, { onNotice }); await vi.runAllTimersAsync(); await promise; expect(onNotice).not.toHaveBeenCalled(); vi.useRealTimers(); }); it("resets consecutive failure counter on success", async () => { vi.useFakeTimers(); mockFetch .mockRejectedValueOnce(new Error("fail 1")) .mockRejectedValueOnce(new Error("fail 2")) .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) // success resets counter .mockRejectedValueOnce(new Error("fail 3")) .mockRejectedValueOnce(new Error("fail 4")); const onNotice = vi.fn(); const places = [ makePlace("Bad 1"), makePlace("Bad 2"), makePlace("Good"), makePlace("Bad 3"), makePlace("Bad 4"), ]; const promise = geocodePlaces(places, { onNotice }); await vi.runAllTimersAsync(); await promise; // Counter was reset by "Good", so only 2 consecutive after that expect(onNotice).not.toHaveBeenCalled(); vi.useRealTimers(); }); it("does not crash if onNotice is not provided", async () => { vi.useFakeTimers(); mockFetch .mockRejectedValueOnce(new Error("fail 1")) .mockRejectedValueOnce(new Error("fail 2")) .mockRejectedValueOnce(new Error("fail 3")); const places = [ makePlace("Bad 1"), makePlace("Bad 2"), makePlace("Bad 3"), ]; // No callbacks at all — should not throw const promise = geocodePlaces(places); await vi.runAllTimersAsync(); await promise; vi.useRealTimers(); }); });