← back to La Socrata Ingester

test/auto-repoint.test.mjs

150 lines

// TK-10955: zero-network, zero-database proof of the VERIFIED auto-repoint policy
// (Steve 2026-09-11, option B/D). Positional NavigateLA layer ids shifted +1 twice in six
// days, so fail-loud-only meant a roughly weekly outage. The policy is now: find the layer
// by exact name, PROVE it on the authoritative per-layer plane, then ingest — and still
// fail loud on anything we cannot prove.
//
// Every check below is a NEGATIVE control except 1 and 2: the point of this suite is that
// the resolver REFUSES to move without proof, not merely that it moves when it can.
import assert from 'node:assert/strict';
import { resolveLayerIdentity, _resetCatalogCache } from '../src/adapters/arcgis.js';

const NAV = 'https://maps.lacity.org/arcgis/rest/services/Mapping/NavigateLA/MapServer';
const src = (id, extra = {}) => ({
  endpoint: `${NAV}/${id}`, expectName: 'Council Districts',
  nameFields: ['District_Name'], ...extra,
});
// A fake upstream: map of id -> layer metadata, plus the service catalog at '?f=json'.
const F = (name, { type = 'Feature Layer', fields = ['OBJECTID', 'District_Name'] } = {}) =>
  ({ name, type, fields: fields.map((n) => ({ name: n })) });
function upstream(layers, { catalog = true, catalogLayers = null } = {}) {
  return async (url) => {
    if (url === `${NAV}?f=json`) {
      if (!catalog) throw new Error('catalog unavailable');
      const src = catalogLayers ?? layers;
      return { layers: Object.entries(src).map(([id, m]) => ({ id: Number(id), name: m.name, type: m.type })) };
    }
    const m = /\/(\d+)\?f=json$/.exec(url);
    const hit = m && layers[m[1]];
    if (!hit) throw new Error(`no layer ${m && m[1]}`);
    return hit;
  };
}
const run = (s, f) => { _resetCatalogCache(); return resolveLayerIdentity(s, f); };

// 1. Pinned id still correct → no repoint, endpoint untouched.
let r = await run(src(419), upstream({ 419: F('Council Districts') }));
assert.equal(r.repointed, false);
assert.equal(r.endpoint, `${NAV}/419`);

// 2. The real 2026-09-11 shift: pinned 419 drifted, truth moved to 420 → repoint via catalog.
r = await run(src(419), upstream({
  419: F('Contract Administration Inspection Districts'),
  420: F('Council Districts'),
}));
assert.equal(r.repointed, true);
assert.equal(r.to, 420);
assert.equal(r.via, 'catalog');
assert.equal(r.endpoint, `${NAV}/420`);

// 3. STALE CATALOG (measured live on 2026-09-11: catalog served the OLD index while
//    per-layer endpoints served the new one). The catalog says 419, but 419 re-proves as
//    something else, so the catalog hit must be REJECTED and the window scan must find 420.
r = await run(src(419), upstream(
  { 419: F('Contract Administration Inspection Districts'), 420: F('Council Districts') },
  { catalogLayers: { 419: F('Council Districts'), 420: F('Previous Council Districts') } }
));
assert.equal(r.repointed, true);
assert.equal(r.to, 420, 'a stale catalog must not decide the id');
assert.match(r.via, /^window/);

// 4. NEGATIVE — a GROUP LAYER with our exact name must never be repointed onto.
//    (This is the documented layer-73 trap: same name, wrong type.)
await assert.rejects(
  () => run(src(419), upstream({
    419: F('Contract Administration Inspection Districts'),
    420: F('Council Districts', { type: 'Group Layer' }),
  })),
  /Could not PROVE/,
  'a same-named Group Layer is not a valid repoint target'
);

// 5. NEGATIVE — a same-named REPLACEMENT dataset lacking our fields must be refused.
await assert.rejects(
  () => run(src(419), upstream({
    419: F('Contract Administration Inspection Districts'),
    420: F('Council Districts', { fields: ['FID', 'SOMETHING_ELSE'] }),
  })),
  /Could not PROVE/,
  'a same-named layer missing oidField/nameFields is not ours'
);

// 6. NEGATIVE — AMBIGUITY (two proved matches) must fail loud, not pick one.
await assert.rejects(
  () => run(src(419), upstream({
    419: F('Contract Administration Inspection Districts'),
    420: F('Council Districts'),
    418: F('Council Districts'),
  }, { catalog: false })),
  /ambiguous|Could not PROVE/i,
  'two matching layers must fail loud'
);

// 7. NEGATIVE — a genuine RENAME (our name exists nowhere) must fail loud.
await assert.rejects(
  () => run(src(419), upstream({ 419: F('Something Entirely Different') }, { catalog: false })),
  /Could not PROVE/,
  'a rename must fail loud, never silently follow'
);

// 8. NEGATIVE — SCHEMA drift on the pinned id (right name, wrong shape) must fail loud and
//    must NOT wander off looking for another layer.
await assert.rejects(
  () => run(src(419), upstream({ 419: F('Council Districts', { fields: ['FID'] }) })),
  /layer schema drift/,
  'same name + wrong schema means the dataset was replaced'
);

// 9. The kill switch still works: LA_NO_AUTO_REPOINT=1 restores fail-loud-only.
process.env.LA_NO_AUTO_REPOINT = '1';
await assert.rejects(
  () => run(src(419), upstream({ 419: F('Contract Administration Inspection Districts'), 420: F('Council Districts') })),
  /repoint the layer id/,
  'the kill switch must restore pure fail-loud'
);
delete process.env.LA_NO_AUTO_REPOINT;

// 10. Unpinned sources (no expectName) are untouched — the guard stays opt-in.
r = await run({ endpoint: `${NAV}/5` }, async () => { throw new Error('must not be called'); });
assert.equal(r.repointed, false);
assert.equal(r.endpoint, `${NAV}/5`);

// 11. A FAILED catalog fetch must NOT be cached. Caching it would let one transient blip on
//     the first drifted source silently downgrade every LATER source in the same run to the
//     window-scan path. Here the catalog throws once, then succeeds; the second resolve must
//     use the catalog path, proving the failure was forgotten rather than memoised.
{
  let catalogCalls = 0;
  const layers = { 419: F('Contract Administration Inspection Districts'), 420: F('Council Districts') };
  const flaky = async (url) => {
    if (url === `${NAV}?f=json`) {
      catalogCalls++;
      if (catalogCalls === 1) throw new Error('transient catalog blip');
      return { layers: Object.entries(layers).map(([id, m]) => ({ id: Number(id), name: m.name, type: m.type })) };
    }
    const m = /\/(\d+)\?f=json$/.exec(url);
    const hit = m && layers[m[1]];
    if (!hit) throw new Error(`no layer ${m && m[1]}`);
    return hit;
  };
  _resetCatalogCache();
  const first = await resolveLayerIdentity(src(419), flaky);   // catalog throws -> window path
  assert.equal(first.to, 420, 'window scan must still recover when the catalog is down');
  assert.match(first.via, /^window/);
  const second = await resolveLayerIdentity(src(419), flaky);  // catalog retried -> catalog path
  assert.equal(second.via, 'catalog', 'a failed catalog fetch must not be cached for the run');
  assert.equal(catalogCalls, 2, 'the catalog must be retried, not memoised as null');
}

console.log('✔ verified auto-repoint: 11/11 checks pass (no network, no database)');