← back to Directory Core

test/geo.test.ts

193 lines

import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
  haversineMi,
  haversineKm,
  geocodeZip,
  makePgZipCache,
  type LatLng,
  type ZipCacheAdapter,
} from '../src/geo.js';

describe('haversineMi', () => {
  test('returns 0 for identical points', () => {
    const p: LatLng = { lat: 34.0522, lng: -118.2437 };
    assert.equal(haversineMi(p, p), 0);
  });

  test('LA to NYC is ~2445 mi (within 1%)', () => {
    const la: LatLng = { lat: 34.0522, lng: -118.2437 };
    const nyc: LatLng = { lat: 40.7128, lng: -74.006 };
    const d = haversineMi(la, nyc);
    assert.ok(d > 2420 && d < 2470, `expected ~2445 mi, got ${d}`);
  });

  test('symmetry: d(a,b) == d(b,a)', () => {
    const a: LatLng = { lat: 34.05, lng: -118.24 };
    const b: LatLng = { lat: 40.71, lng: -74.0 };
    assert.equal(haversineMi(a, b), haversineMi(b, a));
  });

  test('handles antipodal points (max distance ~12,438 mi)', () => {
    const a: LatLng = { lat: 0, lng: 0 };
    const b: LatLng = { lat: 0, lng: 180 };
    const d = haversineMi(a, b);
    // half earth circumference at equator
    assert.ok(d > 12400 && d < 12500, `antipodal expected ~12438 mi, got ${d}`);
  });
});

describe('haversineKm', () => {
  test('returns 0 for identical points', () => {
    const p: LatLng = { lat: 51.5074, lng: -0.1278 };
    assert.equal(haversineKm(p, p), 0);
  });

  test('London to Paris is ~344 km (within 2%)', () => {
    const london: LatLng = { lat: 51.5074, lng: -0.1278 };
    const paris: LatLng = { lat: 48.8566, lng: 2.3522 };
    const d = haversineKm(london, paris);
    assert.ok(d > 337 && d < 351, `expected ~344 km, got ${d}`);
  });

  test('km result ≈ 1.609 × mi result for the same pair', () => {
    const a: LatLng = { lat: 34.05, lng: -118.24 };
    const b: LatLng = { lat: 40.71, lng: -74.0 };
    const mi = haversineMi(a, b);
    const km = haversineKm(a, b);
    const ratio = km / mi;
    assert.ok(ratio > 1.605 && ratio < 1.615, `mi-to-km ratio off: ${ratio}`);
  });
});

describe('geocodeZip — input validation', () => {
  test('rejects null/undefined', async () => {
    assert.equal(await geocodeZip(null), null);
    assert.equal(await geocodeZip(undefined), null);
  });

  test('rejects non-5-digit strings', async () => {
    assert.equal(await geocodeZip(''), null);
    assert.equal(await geocodeZip('1234'), null);
    assert.equal(await geocodeZip('abcde'), null);
    assert.equal(await geocodeZip('123456'), null);
  });

  test('accepts numeric ZIP', async () => {
    // numeric input that's only 5 digits should pass validation; we mock fetch
    // to confirm it gets through to the URL construction.
    let calledUrl = '';
    const fetchImpl = (async (url: string) => {
      calledUrl = url;
      return new Response(JSON.stringify([{ lat: '34.09', lon: '-118.4' }]), { status: 200 });
    }) as typeof fetch;
    const r = await geocodeZip(90210, { fetchImpl });
    assert.ok(calledUrl.includes('90210'), `url should include 90210, got ${calledUrl}`);
    assert.deepEqual(r, { lat: 34.09, lng: -118.4, source: 'nominatim' });
  });
});

describe('geocodeZip — cache path', () => {
  test('cache hit short-circuits Nominatim', async () => {
    let fetchCalled = false;
    const fetchImpl = (async () => {
      fetchCalled = true;
      return new Response('[]', { status: 200 });
    }) as typeof fetch;
    const cache: ZipCacheAdapter = {
      async get() { return { lat: 34.0, lng: -118.0 }; },
      async put() { /* not called */ },
    };
    const r = await geocodeZip('90210', { cache, fetchImpl });
    assert.equal(fetchCalled, false, 'fetch should not be called on cache hit');
    assert.deepEqual(r, { lat: 34.0, lng: -118.0, source: 'cache' });
  });

  test('cache miss → fetch → put', async () => {
    const puts: Array<[string, LatLng, string]> = [];
    const fetchImpl = (async () =>
      new Response(JSON.stringify([{ lat: '40.7128', lon: '-74.006' }]), { status: 200 })) as typeof fetch;
    const cache: ZipCacheAdapter = {
      async get() { return null; },
      async put(zip, coords, source) { puts.push([zip, coords, source]); },
    };
    const r = await geocodeZip('10001', { cache, fetchImpl });
    assert.equal(r?.lat, 40.7128);
    assert.equal(r?.lng, -74.006);
    assert.equal(r?.source, 'nominatim');
    assert.equal(puts.length, 1);
    assert.equal(puts[0][0], '10001');
    assert.equal(puts[0][2], 'nominatim');
  });
});

describe('geocodeZip — failure modes', () => {
  test('returns null on Nominatim non-200', async () => {
    const fetchImpl = (async () => new Response('rate limited', { status: 429 })) as typeof fetch;
    assert.equal(await geocodeZip('90210', { fetchImpl }), null);
  });

  test('returns null on empty result array', async () => {
    const fetchImpl = (async () => new Response('[]', { status: 200 })) as typeof fetch;
    assert.equal(await geocodeZip('90210', { fetchImpl }), null);
  });

  test('returns null on fetch throw', async () => {
    const fetchImpl = (async () => { throw new Error('network down'); }) as typeof fetch;
    assert.equal(await geocodeZip('90210', { fetchImpl }), null);
  });

  test('cache.put failure does not break the geocode result', async () => {
    const fetchImpl = (async () =>
      new Response(JSON.stringify([{ lat: '34', lon: '-118' }]), { status: 200 })) as typeof fetch;
    const cache: ZipCacheAdapter = {
      async get() { return null; },
      async put() { throw new Error('db down'); },
    };
    const r = await geocodeZip('90210', { cache, fetchImpl });
    assert.equal(r?.lat, 34);
  });
});

describe('makePgZipCache', () => {
  test('get translates row to LatLng', async () => {
    const queryFn = async <T = unknown>(_text: string, _params: unknown[]) =>
      ({ rows: [{ lat: 34.05, lng: -118.24 }] as unknown as T[] });
    const cache = makePgZipCache(queryFn);
    const r = await cache.get('90210');
    assert.deepEqual(r, { lat: 34.05, lng: -118.24 });
  });

  test('get returns null when no rows', async () => {
    const queryFn = async <T = unknown>(_t: string, _p: unknown[]) => ({ rows: [] as T[] });
    const cache = makePgZipCache(queryFn);
    assert.equal(await cache.get('00000'), null);
  });

  test('put issues an INSERT and returns', async () => {
    let calledText = '';
    let calledParams: unknown[] = [];
    const queryFn = async <T = unknown>(text: string, params: unknown[]) => {
      calledText = text;
      calledParams = params;
      return { rows: [] as T[] };
    };
    const cache = makePgZipCache(queryFn);
    await cache.put('90210', { lat: 34, lng: -118 }, 'nominatim');
    assert.match(calledText, /INSERT INTO zip_centroids/);
    assert.match(calledText, /ON CONFLICT \(zip\) DO NOTHING/);
    assert.deepEqual(calledParams, ['90210', 34, -118, 'nominatim']);
  });

  test('put honors custom table name', async () => {
    let calledText = '';
    const queryFn = async <T = unknown>(text: string, _p: unknown[]) => {
      calledText = text;
      return { rows: [] as T[] };
    };
    const cache = makePgZipCache(queryFn, 'my_custom_zips');
    await cache.put('90210', { lat: 34, lng: -118 }, 'nominatim');
    assert.match(calledText, /INSERT INTO my_custom_zips/);
  });
});