← back to Nationalrealestate

src/server/listing-lifecycle.dbtest.ts

166 lines

/**
 * TK-10155 withdrawal-snapshot DB self-test — end-to-end against the real schema.
 *   run: `tsx src/server/listing-lifecycle.dbtest.ts`  (or `npm run test:lifecycle:db`)
 *
 * Follows the house self-test pattern (provenance_selftest.ts): no test framework, exits
 * non-zero on failure, uses a clearly-SYNTHETIC source ('selftest-tk10155') so it can never
 * collide with, or leave residue in, real ingested data — it purges everything it created.
 *
 * Proves the HEADLINE promise on the real tables: when a listing transitions to WITHDRAWN,
 * recordLifecycle preserves a FULL listing_snapshot (the entire listing row + joined
 * broker/firm/region names) so the property data survives after the listing vanishes — AND
 * that the preserved snapshot can reconstruct the listing after the row itself is deleted.
 */
import { pool, query } from '../../db/pool.ts';
import {
  recordLifecycle,
  ensureBaselineSnapshot,
  type LifecycleListing,
  type LifecycleCtx,
} from './listing-lifecycle.ts';

const SOURCE = 'selftest-tk10155';
const SID = 'SNAP-1';
const DAY = 86_400_000;

let failed = 0;
function check(ok: boolean, label: string, detail?: unknown) {
  if (ok) { console.log(`  ok   ${label}`); return; }
  failed++;
  console.error(`  FAIL ${label}${detail !== undefined ? `\n       ${JSON.stringify(detail)}` : ''}`);
}

async function purge() {
  await query(`DELETE FROM listing_snapshot        WHERE source=$1`, [SOURCE]);
  await query(`DELETE FROM listing_lifecycle_event WHERE source=$1`, [SOURCE]);
  await query(`DELETE FROM listing                 WHERE source=$1`, [SOURCE]);
}

async function main() {
  await purge(); // clean slate in case a prior run aborted before its own purge

  const now = new Date();
  const address = '742 Evergreen Terrace, Springfield';
  const price = 875000;

  // 1. insert a synthetic listing, freshly seen.
  const ins = await query<{ id: number }>(
    `INSERT INTO listing (source, source_id, address, price, beds, baths, sqft, status, first_seen, last_seen)
     VALUES ($1,$2,$3,$4,4,3,2600,NULL,$5,$5) RETURNING id`,
    [SOURCE, SID, address, price, new Date(now.getTime() - 120 * DAY).toISOString()],
  );
  const id = ins.rows[0].id;
  check(Number.isInteger(id) && id > 0, 'synthetic listing inserted', { id });

  const listing: LifecycleListing = {
    id, source: SOURCE, source_id: SID, address, price,
    status: null, first_seen: new Date(now.getTime() - 120 * DAY), last_seen: new Date(now),
  };
  // full rescan happened now → gone-from-feed aging is eligible.
  const ctxSeen: LifecycleCtx = { sourceMaxLastSeen: new Date(now), lastFullRescanAt: new Date(now), now };

  // 2. first record. first_seen is 120d ago so it classifies 'active' (not 'new'), and 'active'
  //    is NOT a snapshot state — so no snapshot yet. The withdrawal snapshot (step 3) is the one
  //    the durability promise hinges on.
  const r1 = await recordLifecycle(query, listing, ctxSeen);
  check(r1.state === 'active' && r1.eventInserted && !r1.snapshotInserted,
    "first-see (old first_seen) → 'active' event, no snapshot (not a snapshot state)", r1);

  // 3. now the listing has vanished from the feed for > WITHDRAWN_DAYS. Its last_seen is old,
  //    but the source's reference (max last_seen) has moved forward (later listings were seen).
  const goneListing: LifecycleListing = {
    ...listing,
    last_seen: new Date(now.getTime() - 45 * DAY), // 45d behind the source reference
  };
  const ctxGone: LifecycleCtx = {
    sourceMaxLastSeen: new Date(now),      // source was refreshed at "now"
    lastFullRescanAt: new Date(now),       // via a full rescan
    now,
  };
  const r2 = await recordLifecycle(query, goneListing, ctxGone);
  check(
    r2.state === 'withdrawn' && r2.prev_state === 'active' && r2.snapshotInserted,
    'gone-from-feed (active → withdrawn) + a preserved snapshot',
    r2,
  );

  // 4. assert the WITHDRAWAL snapshot carries the full property info + joined names.
  const snapRes = await query<{ captured_reason: string; snapshot: any }>(
    `SELECT captured_reason, snapshot FROM listing_snapshot
      WHERE source=$1 AND source_id=$2 AND captured_reason='withdrawn'
      ORDER BY captured_at DESC LIMIT 1`,
    [SOURCE, SID],
  );
  const snap = snapRes.rows[0]?.snapshot;
  check(!!snap, 'withdrawal snapshot row exists');
  if (snap) {
    check(Number(snap.price) === price, 'snapshot preserved price', snap.price);
    check(snap.address === address, 'snapshot preserved address', snap.address);
    check(snap.beds != null && snap.sqft != null, 'snapshot preserved beds/sqft', { beds: snap.beds, sqft: snap.sqft });
    check('broker_name' in snap && 'firm_name' in snap && 'region_name' in snap,
      'snapshot carries joined broker/firm/region name fields (nullable ok)');
  }

  // 5. reconstruction test: DELETE the listing row (it "vanished"), the snapshot must still
  //    fully describe it — the whole point of preservation.
  await query(`DELETE FROM listing WHERE id=$1`, [id]);
  const afterDel = await query<{ snapshot: any }>(
    `SELECT snapshot FROM listing_snapshot WHERE source=$1 AND source_id=$2 AND captured_reason='withdrawn' LIMIT 1`,
    [SOURCE, SID],
  );
  const preserved = afterDel.rows[0]?.snapshot;
  check(
    !!preserved && preserved.address === address && Number(preserved.price) === price,
    'after listing row DELETED, snapshot still fully reconstructs the listing',
    preserved && { address: preserved.address, price: preserved.price },
  );

  // 6. Cody FIX-FIRST gate: the 'active'-with-no-snapshot gap. A listing whose first-see snapshot
  //    hook failed and that derives straight to 'active' (not a snapshot state) has NO snapshot —
  //    ensureBaselineSnapshot (called by the sweep) must rescue it, and be idempotent.
  const SID2 = 'SNAP-2';
  await query(`DELETE FROM listing_snapshot WHERE source=$1 AND source_id=$2`, [SOURCE, SID2]);
  await query(`DELETE FROM listing_lifecycle_event WHERE source=$1 AND source_id=$2`, [SOURCE, SID2]);
  await query(`DELETE FROM listing WHERE source=$1 AND source_id=$2`, [SOURCE, SID2]);
  const ins2 = await query<{ id: number }>(
    `INSERT INTO listing (source, source_id, address, price, first_seen, last_seen)
     VALUES ($1,$2,'9 Nowhere Rd, Ogdenville',321000,$3,$3) RETURNING id`,
    [SOURCE, SID2, new Date(now.getTime() - 300 * DAY).toISOString()],
  );
  const id2 = ins2.rows[0].id;
  const active: LifecycleListing = {
    id: id2, source: SOURCE, source_id: SID2, address: '9 Nowhere Rd, Ogdenville', price: 321000,
    status: null, first_seen: new Date(now.getTime() - 300 * DAY), last_seen: new Date(now),
  };
  // record only an 'active' event (simulating first-see snapshot hook having failed → no snapshot).
  // classifyState → 'active' (first_seen 300d ago, currently seen); 'active' is NOT a snapshot state,
  // so recordLifecycle writes the event but NO snapshot — reproducing the exact gap Cody flagged.
  const ra = await recordLifecycle(query, active, ctxSeen);
  check(ra.state === 'active' && ra.eventInserted && !ra.snapshotInserted,
    "recordLifecycle on an 'active' listing writes event, NO snapshot (reproduces the gap)", ra);
  const snapCountBefore = await query<{ n: string }>(
    `SELECT count(*)::text n FROM listing_snapshot WHERE source=$1 AND source_id=$2`, [SOURCE, SID2]);
  check(Number(snapCountBefore.rows[0].n) === 0, "active-derived listing starts with NO snapshot (the gap)");
  const rescued = await ensureBaselineSnapshot(query, active);
  check(rescued === true, 'ensureBaselineSnapshot rescues the snapshot-less listing');
  const snapCountAfter = await query<{ n: string }>(
    `SELECT count(*)::text n FROM listing_snapshot WHERE source=$1 AND source_id=$2`, [SOURCE, SID2]);
  check(Number(snapCountAfter.rows[0].n) === 1, 'exactly one baseline snapshot now exists');
  const again = await ensureBaselineSnapshot(query, active);
  check(again === false, 'ensureBaselineSnapshot is idempotent (no-op when a snapshot exists)');
  await query(`DELETE FROM listing_snapshot WHERE source=$1 AND source_id=$2`, [SOURCE, SID2]);
  await query(`DELETE FROM listing_lifecycle_event WHERE source=$1 AND source_id=$2`, [SOURCE, SID2]);
  await query(`DELETE FROM listing WHERE source=$1 AND source_id=$2`, [SOURCE, SID2]);

  await purge();
  console.log(`\n${failed === 0 ? 'PASS — withdrawal snapshot survives listing deletion + no-snapshot gap closed' : `FAIL — ${failed} assertion(s) failed`}\n`);
  await pool.end();
  process.exit(failed === 0 ? 0 : 1);
}

main().catch(async (e) => {
  console.error(e);
  try { await purge(); await pool.end(); } catch {}
  process.exit(1);
});