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.
206 lines
6.7 KiB
TypeScript
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");
|
|
});
|
|
});
|