Files
lam-agent-vicinae/src/__tests__/geo.test.ts
T
adrianbonpin 51c16b678f feat: opt-in IP geolocation for get_location
Adds the one thing the extension could not answer before: roughly where
the user actually is, rather than which zone their clock is set to.

- `lib/geo.ts` resolves a place from ipapi.co. Chosen because it is HTTPS
  and needs no API key; ip-api.com, the other obvious free option, is
  HTTP-only on its free tier, which is not a place to send an IP address.
- Off by default, and skipped entirely when the user has set Location: the
  better answer is already there, so spending their privacy on a worse one
  would be indefensible. `ipPlace` is only wired into ToolContext when the
  preference is on, so an off setting cannot reach the network at all.
- Cached for 6 hours. A city does not change between questions, and every
  lookup hands the provider another copy of the address. Set Up Lam Agent
  clears it on re-test, alongside the vision cache.
- Failures are returned, not swallowed: a rate limit or an offline machine
  says so in the tool output while the region answer stays available. The
  provider signals some failures with error: true and HTTP 200, so the body
  is checked as well as the status.
- The tool output always names the provider and calls the result city-level
  rather than exact. It also flags an IP timezone that disagrees with the
  device clock, which usually means a VPN.
- The README claimed no geolocation service ever saw the user's address.
  That is now conditional, so the section was rewritten rather than left to
  quietly become untrue.

Deliberately not done: GPS. CoreLocation has no CLI on macOS and GeoClue's
usual provider resolves nearby Wi-Fi through a Mozilla service anyway.

Verified: 341 tests (25 new), tsc, eslint, prettier, and vici build. I did
not make a live API call, because doing so would send this machine's IP to
the provider as the exact flow being opted into.
2026-10-04 15:52:22 +08:00

206 lines
6.7 KiB
TypeScript

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("<html>nope</html>", { 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");
});
});