import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __resetLocalStorage } from "../__mocks__/@vicinae/api"; import { GEO_TTL_MS, buildPlace, forgetGeoPlace, ipPlace } from "../lib/geo"; /** * The only code in the extension that sends anything about the user to a third * party, so the parts worth pinning down are that it caches (rather than leaking * the address on every question), that it never throws, and that a failure is * reported instead of being served as a coarser answer. */ const fetchMock = vi.fn(); function answer(payload: unknown, status = 200): Response { return new Response(JSON.stringify(payload), { status, headers: { "Content-Type": "application/json" }, }); } const CEBU = { city: "Cebu City", region: "Central Visayas", country_name: "Philippines", latitude: 10.3167, longitude: 123.8907, timezone: "Asia/Manila", }; beforeEach(() => { __resetLocalStorage(); fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("buildPlace", () => { it("joins the parts that exist", () => { expect(buildPlace({ city: "Cebu City", region: "Central Visayas", country: "Philippines" })).toBe( "Cebu City, Central Visayas, Philippines", ); }); it("drops repeats, which providers return for city-states", () => { // "Singapore, Singapore, Singapore" reads like a bug rather than a place. expect(buildPlace({ city: "Singapore", region: "Singapore", country: "Singapore" })).toBe("Singapore"); }); it("ignores repeats that differ only in case", () => { expect(buildPlace({ city: "Manila", region: "manila" })).toBe("Manila"); }); it("drops empty and whitespace parts", () => { expect(buildPlace({ city: " ", region: "", country: "Philippines" })).toBe("Philippines"); }); it("returns nothing when there is nothing", () => { expect(buildPlace({})).toBe(""); }); }); describe("ipPlace", () => { it("resolves a place from a provider answer", async () => { fetchMock.mockResolvedValueOnce(answer(CEBU)); const result = await ipPlace({ now: 1_000 }); expect(result.status).toBe("ok"); if (result.status !== "ok") return; expect(result.place.place).toBe("Cebu City, Central Visayas, Philippines"); expect(result.place.latitude).toBe(10.3167); expect(result.place.timezone).toBe("Asia/Manila"); expect(result.place.source).toBe("ipapi.co"); }); it("asks for JSON with no cache-busting that a cache would defeat", async () => { fetchMock.mockResolvedValueOnce(answer(CEBU)); await ipPlace({ now: 1_000 }); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe("https://ipapi.co/json/"); expect(init.headers.Accept).toBe("application/json"); }); it("serves a repeat from the cache instead of re-sending the address", async () => { fetchMock.mockResolvedValueOnce(answer(CEBU)); await ipPlace({ now: 1_000 }); const second = await ipPlace({ now: 2_000 }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(second.status).toBe("ok"); }); it("re-fetches once the cached place is stale", async () => { fetchMock.mockResolvedValueOnce(answer(CEBU)); await ipPlace({ now: 1_000 }); fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" })); const later = await ipPlace({ now: 1_000 + GEO_TTL_MS + 1 }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(later.status === "ok" && later.place.city).toBe("Davao City"); }); it("treats exactly the TTL as still fresh", async () => { fetchMock.mockResolvedValueOnce(answer(CEBU)); await ipPlace({ now: 1_000 }); await ipPlace({ now: 1_000 + GEO_TTL_MS }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("ignores the cache when asked to refresh", async () => { fetchMock.mockResolvedValueOnce(answer(CEBU)); await ipPlace({ now: 1_000 }); fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" })); const refreshed = await ipPlace({ now: 1_001, refresh: true }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(refreshed.status === "ok" && refreshed.place.city).toBe("Davao City"); }); it("reports a rate limit by status rather than pretending to know the place", async () => { fetchMock.mockResolvedValueOnce(new Response("slow down", { status: 429 })); const result = await ipPlace({ now: 1_000 }); expect(result).toEqual({ status: "failed", message: "ipapi.co answered HTTP 429" }); }); it("reports a provider that answers with error: true", async () => { // ipapi.co signals failure in the body while still answering 200. fetchMock.mockResolvedValueOnce(answer({ error: true, reason: "RateLimited", message: "Too many" })); const result = await ipPlace({ now: 1_000 }); expect(result).toEqual({ status: "failed", message: "Too many" }); }); it("reports a body that is not JSON", async () => { fetchMock.mockResolvedValueOnce(new Response("nope", { status: 200 })); const result = await ipPlace({ now: 1_000 }); expect(result.status).toBe("failed"); }); it("reports an answer with no usable place", async () => { fetchMock.mockResolvedValueOnce(answer({ city: null, region: null, country_name: null })); const result = await ipPlace({ now: 1_000 }); expect(result).toEqual({ status: "failed", message: "ipapi.co returned no usable place" }); }); it("survives the network being down without throwing", async () => { fetchMock.mockRejectedValueOnce(new Error("offline")); const result = await ipPlace({ now: 1_000 }); expect(result.status).toBe("failed"); }); it("does not cache a failure", async () => { fetchMock.mockRejectedValueOnce(new Error("offline")); await ipPlace({ now: 1_000 }); fetchMock.mockResolvedValueOnce(answer(CEBU)); const second = await ipPlace({ now: 1_001 }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(second.status).toBe("ok"); }); it("ignores a corrupt cache entry rather than failing", async () => { const { LocalStorage } = await import("../__mocks__/@vicinae/api"); await LocalStorage.setItem("lam.geo", "{not json"); fetchMock.mockResolvedValueOnce(answer(CEBU)); await expect(ipPlace({ now: 1_000 })).resolves.toMatchObject({ status: "ok" }); }); }); describe("forgetGeoPlace", () => { it("drops the cached place so a move is not pinned for hours", async () => { fetchMock.mockResolvedValueOnce(answer(CEBU)); await ipPlace({ now: 1_000 }); await forgetGeoPlace(); fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" })); const after = await ipPlace({ now: 1_001 }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(after.status === "ok" && after.place.city).toBe("Davao City"); }); });