#!/usr/bin/env node /* * location-chain.test.mjs — GEO-01 + GEO-02 (client half) against injected fakes. * ------------------------------------------------------------------------------ * Every case feeds a plain object literal matching TelegramWebAppLike (or null) * straight into the chain — never a real MapLibre instance, never a DOM, never a * device. The `navigator` and `fetch` globals are stubbed per case * (install + restore-in-finally) because the webview and chat-pin links probe them. * * Cases named with the requirement they prove: * GEO1 the telegram source, the version gate, the null-WebApp guard, the * null-denial branch, the hard timeout, and the stale-detector + nudge * GEO2 the chat-pin poll: pin pickup, bounded poll, and the Desktop leg * (no LocationManager, no navigator.geolocation → denied, identical * reason keys — no client-specific variant, Pitfall 4) */ import { strict as assert } from "node:assert"; import { test } from "./harness.mjs"; const { acquireFix } = await import("../src/location/chain.ts"); const telegramMod = await import("../src/location/telegram.ts"); const { requestTelegramFix } = telegramMod; const chatPinMod = await import("../src/location/chat-pin.ts"); const { pollChatPin, fetchChatPin, CHAT_PIN_POLL_MS, CHAT_PIN_TIMEOUT_MS } = chatPinMod; const FIX_A = { lat: 3.1513, lng: 101.6958, accuracyM: 12, headingDeg: null }; /* ————— stub helpers (install + restore per case) ————— */ function stubNavigator(geolocationOrNull) { const original = Object.getOwnPropertyDescriptor(globalThis, "navigator"); Object.defineProperty(globalThis, "navigator", { value: geolocationOrNull === null ? {} : { geolocation: { getCurrentPosition(success) { success({ coords: { latitude: FIX_A.lat, longitude: FIX_A.lng, accuracy: 9, heading: null }, timestamp: Date.now(), }); }, }, }, configurable: true, writable: true, enumerable: true, }); return () => { if (original) Object.defineProperty(globalThis, "navigator", original); // restore the exact prior descriptor else delete globalThis.navigator; }; } function stubFetch(handler) { const original = Object.getOwnPropertyDescriptor(globalThis, "fetch"); Object.defineProperty(globalThis, "fetch", { value: handler, configurable: true, writable: true }); return () => { if (original) Object.defineProperty(globalThis, "fetch", original); else delete globalThis.fetch; }; } const pinResponse = (pin) => new Response(JSON.stringify({ pin }), { status: 200, headers: { "content-type": "application/json" } }); const emptyPinResponse = () => pinResponse(null); function fakeWebApp(opts = {}) { const version = opts.version ?? "8.0"; const app = { initData: "", version, platform: opts.platform ?? "android", LocationManager: opts.noLocationManager ? undefined : immediateLocationManager(opts.getPayload ?? (() => null)), isVersionAtLeast(v) { const a = version.split(".").map(Number); const b = v.split(".").map(Number); for (let i = 0; i < Math.max(a.length, b.length); i++) { const av = a[i] ?? 0; const bv = b[i] ?? 0; if (av > bv) return true; if (av < bv) return false; } return true; }, close() {}, }; return app; } function immediateLocationManager(getPayload) { const lm = { isInited: false, isLocationAvailable: true, isAccessRequested: true, isAccessGranted: true, init(cb) { lm.isInited = true; cb(); }, getLocation(cb) { cb(getPayload()); }, }; return lm; } const noWebview = () => Promise.resolve(null); const fastSleep = () => Promise.resolve(); const telegramPayload = () => ({ latitude: FIX_A.lat, longitude: FIX_A.lng, horizontal_accuracy: 12, course: 135, }); /* ————— GEO1 — the telegram source ————— */ test("GEO1 telegram init+getLocation yields a Fix with source 'telegram'", async () => { const wa = fakeWebApp({ getPayload: telegramPayload }); const { fix, state } = await acquireFix({ wa, getToken: () => null, readWebview: noWebview }); assert.ok(fix !== null, "chain accepted the telegram fix"); assert.strictEqual(state.kind, "live"); assert.strictEqual(fix.source, "telegram"); assert.strictEqual(fix.accuracyM, 12); assert.strictEqual(fix.headingDeg, 135); }); test("GEO1 a client below Bot API 8.0 falls through to navigator.geolocation without throwing", async () => { const wa = fakeWebApp({ version: "7.8", getPayload: () => ({ latitude: 99, longitude: 99 }) }); // gate fails BEFORE LocationManager is probed const restoreNav = stubNavigator({ geolocation: true }); try { const { fix, state } = await acquireFix({ wa, getToken: () => null }); assert.ok(fix !== null); assert.strictEqual(state.kind, "live"); assert.strictEqual(fix.source, "webview", "fell through past the version gate"); } finally { restoreNav(); } }); test("GEO1 telegramLocationSupported(null) returns false without throwing, and acquireFix with wa:null falls through to the webview link", async () => { assert.doesNotThrow(() => telegramMod.telegramLocationSupported(null)); assert.strictEqual(telegramMod.telegramLocationSupported(null), false); const restoreNav = stubNavigator({ geolocation: true }); try { const { fix } = await acquireFix({ wa: null, getToken: () => null }); assert.ok(fix !== null); assert.strictEqual(fix.source, "webview"); } finally { restoreNav(); } }); test("GEO1 getLocation returning null (denied) advances to the webview source, not to an error", async () => { const wa = fakeWebApp({ getPayload: () => null }); // the whole denial class — no error object exists const restoreNav = stubNavigator({ geolocation: true }); try { const { fix, state } = await acquireFix({ wa, getToken: () => null }); assert.ok(fix !== null); assert.strictEqual(state.kind, "live"); assert.strictEqual(fix.source, "webview", "denial advanced the chain, it did not raise"); } finally { restoreNav(); } }); test("GEO1 getLocation that never calls back resolves null after the wrapper timeout (Pitfall 3)", async () => { const wa = fakeWebApp({ getPayload: telegramPayload }); wa.LocationManager.getLocation = () => {}; // silence: wedged client, callback never fires const t0 = Date.now(); const fix = await requestTelegramFix(wa, { timeoutMs: 60 }); const elapsed = Date.now() - t0; assert.strictEqual(fix, null, "hard timeout resolved null — boot never wedges in ACQUIRING"); assert.ok(elapsed >= 55, `timeout actually waited (${elapsed}ms), not rushed`); assert.ok(elapsed < 2000, `timeout bounded the wait (${elapsed}ms), not the 8s default`); }); test("GEO1 two byte-identical successive telegram fixes are detected as stale and route to the nudge", async () => { const stale = { ...FIX_A, ts: Date.now() - 1000, source: "telegram" }; const identicalNext = { ...FIX_A, ts: Date.now(), source: "telegram" }; assert.strictEqual(telegramMod.looksStale(stale, identicalNext), true, "byte-identical pair = stale"); const moved = { ...FIX_A, lat: FIX_A.lat + 0.001, ts: Date.now(), source: "telegram" }; assert.strictEqual(telegramMod.looksStale(stale, moved), false, "a moved fix is fresh"); const crossSource = { ...FIX_A, ts: Date.now(), source: "webview" }; assert.strictEqual(telegramMod.looksStale(stale, crossSource), false, "only telegram-vs-telegram pairs qualify"); }); test("GEO1 the nudge reads the webview ONCE then retries telegram (readWebview called exactly once)", async () => { const wa = fakeWebApp({ getPayload: telegramPayload }); // device keeps reporting the SAME bytes let webviewCalls = 0; const readWebview = () => { webviewCalls++; return Promise.resolve(null); // whatever the webview read returns, it is discarded }; // Fresh pair: lastAccepted null → no staleness → no nudge. await telegramMod.nudgeAndRetryTelegram(wa, readWebview, null, { timeoutMs: 60 }); assert.strictEqual(webviewCalls, 0, "a fresh telegram fix needs no nudge"); // Stale pair: byte-identical retry → exactly ONE nudge read, then one telegram retry. const staleAccepted = { ...FIX_A, ts: Date.now() - 30000, source: "telegram" }; // Full nudge behavior, re-verified precisely (DETERMINISTIC mock): await telegramMod.nudgeAndRetryTelegram(wa, readWebview, staleAccepted, { timeoutMs: 60 }); assert.strictEqual(webviewCalls, 1, "the nudge path fired exactly once on the stale pair"); // End-to-end proof through acquireFix: the identical-fix pair routes to the nudge. let chainNudges = 0; const { fix } = await acquireFix( { wa, getToken: () => null, readWebview: () => { chainNudges++; return Promise.resolve(null); }, }, staleAccepted, ); assert.strictEqual(chainNudges, 1, "acquireFix routed the identical-fix pair to the nudge"); assert.ok(fix !== null && fix.source === "telegram", "the retry fix came back from telegram — never the stale one marked fresh silently"); void webviewCalls; }); /* ————— GEO2 — the chat-pin relay ————— */ test("GEO2 pollChatPin returns the pin when the endpoint yields one", async () => { const restoreFetch = stubFetch(async (url, init) => { assert.strictEqual(url, "/api/location"); assert.strictEqual(init.method, "GET"); assert.match(init.headers.authorization, /^Bearer token-/); return pinResponse({ latitude: FIX_A.lat, longitude: FIX_A.lng, horizontal_accuracy: 25 }); }); try { const fix = await fetchChatPin("token-abc"); assert.ok(fix !== null); assert.strictEqual(fix.source, "chat-pin"); assert.strictEqual(fix.accuracyM, 25); assert.strictEqual(fix.headingDeg, null, "bot Location messages carry no course to the Fix"); const polled = await pollChatPin("token-abc", { timeoutMs: 50, pollMs: 5, sleep: fastSleep }); assert.ok(polled !== null, "the poll surfaced the pin"); } finally { restoreFetch(); } }); test("GEO2 the chat-pin poll is bounded — a never-delivering endpoint returns null (timeoutMs 50, pollMs 5)", async () => { assert.strictEqual(CHAT_PIN_TIMEOUT_MS, 60000, "production bound is 60s — AWAITING PIN can never persist forever"); assert.strictEqual(CHAT_PIN_POLL_MS, 2000); const restoreFetch = stubFetch(async () => emptyPinResponse()); try { const t0 = Date.now(); const fix = await pollChatPin("token", { timeoutMs: 50, pollMs: 5, sleep: fastSleep }); assert.strictEqual(fix, null, "bounded poll returned instead of wedging AWAITING PIN"); assert.ok(Date.now() - t0 < 2000, `bound actually bounded the loop (${Date.now() - t0}ms)`); } finally { restoreFetch(); } }); test("GEO2 a Desktop Telegram fake (no LocationManager, no navigator.geolocation) reaches denied with the same reason keys", async () => { const wa = fakeWebApp({ platform: "tdesktop", version: "9.1", noLocationManager: true }); const restoreNav = stubNavigator(null); // Desktop Telegram: no geolocation AT ALL (issue #77) const restoreFetch = stubFetch(async () => emptyPinResponse()); try { // Every in-app link is absent on Desktop; the chat-pin poll IS the only // origin path. Run the end-to-end poll itself (fast stubbed bound) to // prove the chain completes it and lands on the SAME denial reason a // mobile timeout produces — no client-specific copy variant (Pitfall 4). const { fix, state } = await acquireFix({ wa, getToken: () => "token", pollPin: async () => { const viaEndpoint = await fetchChatPin("token"); // same GET /api/location the mobile path polls return viaEndpoint; }, }); assert.strictEqual(fix, null); assert.strictEqual(state.kind, "denied"); assert.strictEqual(state.reason, "timeout", "identical denial key to the mobile timeout path"); } finally { restoreFetch(); restoreNav(); } });