← back to Directory Core

test/match.test.ts

205 lines

import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
  BBOX_CA,
  BBOX_US_CONTINENTAL,
  CENTROID_LA,
  resolveZipAnchor,
  haversineKmSql,
  withinBoundingBoxSql,
  type QueryFn,
  type EmptyReason,
} from '../src/match.js';

describe('Constants', () => {
  test('CA bbox covers LA + SF + the Oregon/Mexico borders', () => {
    // LA
    assert.ok(34.05 >= BBOX_CA.latMin && 34.05 <= BBOX_CA.latMax);
    assert.ok(-118.24 >= BBOX_CA.lngMin && -118.24 <= BBOX_CA.lngMax);
    // SF
    assert.ok(37.77 >= BBOX_CA.latMin && 37.77 <= BBOX_CA.latMax);
    // CA bounds excludes Oregon (Portland 45.5 > 42.05)
    assert.ok(45.5 > BBOX_CA.latMax);
    // CA bounds excludes Tijuana (32.5 boundary; TJ at 32.51 is barely in)
    assert.equal(BBOX_CA.latMin, 32.5);
  });

  test('Continental US bbox includes both coasts but excludes AK', () => {
    // Seattle
    assert.ok(47.6 >= BBOX_US_CONTINENTAL.latMin && 47.6 <= BBOX_US_CONTINENTAL.latMax);
    // Miami
    assert.ok(25.7 >= BBOX_US_CONTINENTAL.latMin && 25.7 <= BBOX_US_CONTINENTAL.latMax);
    // Anchorage AK at 61.2 — should be EXCLUDED
    assert.ok(61.2 > BBOX_US_CONTINENTAL.latMax);
  });

  test('LA centroid matches downtown LA coords', () => {
    assert.equal(CENTROID_LA.lat, 34.0522);
    assert.equal(CENTROID_LA.lng, -118.2437);
  });
});

describe('resolveZipAnchor — input validation', () => {
  test('returns null for null/undefined zip', async () => {
    const q: QueryFn = async () => ({ rows: [] });
    assert.equal(await resolveZipAnchor(q, null, { table: 'organizations' }), null);
    assert.equal(await resolveZipAnchor(q, undefined, { table: 'organizations' }), null);
  });

  test('returns null for non-numeric zip', async () => {
    const q: QueryFn = async () => ({ rows: [] });
    assert.equal(await resolveZipAnchor(q, 'abcde', { table: 'organizations' }), null);
  });

  test('returns null for zip too short (< 3 chars)', async () => {
    const q: QueryFn = async () => ({ rows: [] });
    assert.equal(await resolveZipAnchor(q, '12', { table: 'organizations' }), null);
  });

  test('returns null for zip too long (> 5 chars)', async () => {
    const q: QueryFn = async () => ({ rows: [] });
    assert.equal(await resolveZipAnchor(q, '123456', { table: 'organizations' }), null);
  });
});

describe('resolveZipAnchor — exact match path', () => {
  test('returns centroid from exact ZIP match', async () => {
    const calls: Array<{ text: string; params: unknown[] }> = [];
    const q: QueryFn = async <T = unknown>(text: string, params: unknown[]) => {
      calls.push({ text, params });
      return { rows: [{ lat: 34.05, lng: -118.24, n: 7 }] as unknown as T[] };
    };
    const r = await resolveZipAnchor(q, '90210', { table: 'organizations' });
    assert.deepEqual(r, { lat: 34.05, lng: -118.24 });
    assert.equal(calls.length, 1, 'should not need fallback when exact match returns rows');
    assert.match(calls[0].text, /WHERE zip = \$1/);
    assert.equal(calls[0].params[0], '90210');
  });

  test('falls through to prefix when exact ZIP returns 0 rows', async () => {
    const calls: Array<{ text: string; params: unknown[] }> = [];
    const q: QueryFn = async <T = unknown>(text: string, params: unknown[]) => {
      calls.push({ text, params });
      // First call (exact): no rows. Second call (prefix): 3 rows.
      const isExact = (params[0] as string).length === 5;
      return {
        rows: (isExact
          ? [{ lat: null, lng: null, n: 0 }]
          : [{ lat: 34.10, lng: -118.30, n: 3 }]) as unknown as T[],
      };
    };
    const r = await resolveZipAnchor(q, '90210', { table: 'organizations' });
    assert.deepEqual(r, { lat: 34.10, lng: -118.30 });
    assert.equal(calls.length, 2);
    assert.match(calls[1].text, /WHERE zip LIKE \$1/);
    assert.equal(calls[1].params[0], '902%');
  });

  test('returns null when both exact AND prefix yield zero rows', async () => {
    const q: QueryFn = async <T = unknown>(_t: string, _p: unknown[]) =>
      ({ rows: [{ lat: null, lng: null, n: 0 }] as unknown as T[] });
    const r = await resolveZipAnchor(q, '99999', { table: 'organizations' });
    assert.equal(r, null);
  });
});

describe('resolveZipAnchor — bbox + whereExtra', () => {
  test('passes bbox bounds as $2..$5 params', async () => {
    let captured: unknown[] = [];
    const q: QueryFn = async <T = unknown>(_t: string, params: unknown[]) => {
      captured = params;
      return { rows: [{ lat: 34, lng: -118, n: 1 }] as unknown as T[] };
    };
    await resolveZipAnchor(q, '90210', {
      table: 'organizations',
      bbox: { latMin: 30, latMax: 45, lngMin: -125, lngMax: -110 },
    });
    assert.deepEqual(captured, ['90210', 30, 45, -125, -110]);
  });

  test('defaults to BBOX_CA when bbox omitted', async () => {
    let captured: unknown[] = [];
    const q: QueryFn = async <T = unknown>(_t: string, params: unknown[]) => {
      captured = params;
      return { rows: [{ lat: 34, lng: -118, n: 1 }] as unknown as T[] };
    };
    await resolveZipAnchor(q, '90210', { table: 'organizations' });
    assert.equal(captured[1], BBOX_CA.latMin);
    assert.equal(captured[4], BBOX_CA.lngMax);
  });

  test('appends whereExtra clause to the query', async () => {
    let capturedText = '';
    const q: QueryFn = async <T = unknown>(text: string, _p: unknown[]) => {
      capturedText = text;
      return { rows: [{ lat: 34, lng: -118, n: 1 }] as unknown as T[] };
    };
    await resolveZipAnchor(q, '90210', {
      table: 'organizations',
      whereExtra: "type='law_firm'",
    });
    assert.match(capturedText, /AND \(type='law_firm'\)/);
  });

  test('honors custom table name', async () => {
    let capturedText = '';
    const q: QueryFn = async <T = unknown>(text: string, _p: unknown[]) => {
      capturedText = text;
      return { rows: [{ lat: 34, lng: -118, n: 1 }] as unknown as T[] };
    };
    await resolveZipAnchor(q, '90210', { table: 'animal_businesses' });
    assert.match(capturedText, /FROM animal_businesses/);
  });

  test('3-digit ZIP skips exact-match step', async () => {
    const calls: Array<{ params: unknown[] }> = [];
    const q: QueryFn = async <T = unknown>(_t: string, params: unknown[]) => {
      calls.push({ params });
      return { rows: [{ lat: 34, lng: -118, n: 5 }] as unknown as T[] };
    };
    await resolveZipAnchor(q, '902', { table: 'organizations' });
    assert.equal(calls.length, 1, '3-digit input goes straight to prefix path');
    assert.equal(calls[0].params[0], '902%');
  });
});

describe('haversineKmSql', () => {
  test('produces a SELECT expression with all 4 bind params', () => {
    const sql = haversineKmSql('o.lat', 'o.lng', '$1', '$2');
    assert.match(sql, /6371/, 'uses km earth radius');
    assert.match(sql, /ASIN/);
    assert.match(sql, /SQRT/);
    assert.match(sql, /RADIANS/);
    assert.match(sql, /o\.lat/);
    assert.match(sql, /o\.lng/);
    assert.match(sql, /\$1/);
    assert.match(sql, /\$2/);
    assert.match(sql, /::float8/, 'casts to numeric');
  });

  test('honors custom column + param names', () => {
    const sql = haversineKmSql('row.latitude', 'row.longitude', '$5', '$6');
    assert.match(sql, /row\.latitude/);
    assert.match(sql, /row\.longitude/);
    assert.match(sql, /\$5/);
    assert.match(sql, /\$6/);
    assert.doesNotMatch(sql, /\$1/);
  });
});

describe('withinBoundingBoxSql', () => {
  test('produces a BETWEEN-AND-BETWEEN predicate', () => {
    const sql = withinBoundingBoxSql('o.lat', 'o.lng', '$3', '$4', '$5', '$6');
    assert.match(sql, /o\.lat BETWEEN \$3 AND \$4/);
    assert.match(sql, /o\.lng BETWEEN \$5 AND \$6/);
  });
});

describe('EmptyReason type', () => {
  test('all four reasons are valid string literals', () => {
    const reasons: EmptyReason[] = ['ok', 'no_zip', 'no_anchor', 'no_providers_in_radius'];
    assert.equal(reasons.length, 4);
    assert.deepEqual(new Set(reasons).size, 4);
  });
});