← back to Nationalrealestate

src/server/listing-lifecycle.selftest.ts

223 lines

/**
 * TK-10155 listing-lifecycle self-test — PURE STATE MACHINE (no DB).
 *   run: `tsx src/server/listing-lifecycle.selftest.ts`  (or `npm run test:lifecycle`)
 *
 * No test framework is installed in this repo (the house pattern is a deterministic
 * tsx self-test that exits non-zero on any failure — see provenance_selftest.ts).
 *
 * This file exercises the RISKY part — classifyState(), the pure lifecycle state model,
 * plus recordLifecycle()'s transition/snapshot logic driven by an IN-MEMORY fake db so it
 * needs zero database. It asserts:
 *   (a) every classifyState branch: new / active / stale / withdrawn / closed
 *   (b) the fix-1 false-withdrawal guard: gone-from-feed is suppressed when there was no
 *       full rescan AFTER the listing's last_seen (incremental-only sources)
 *   (c) status strings (sold / withdrawn / cancelled / expired) win over feed-age
 *   (d) recordLifecycle append-only discipline: an event is written ONLY on a state change,
 *       a full snapshot is preserved ONLY on transitions INTO withdrawn/closed/stale/new,
 *       'reappeared' is emitted (and NOT snapshotted) coming back from stale/withdrawn,
 *       and suppressSnapshot skips the state-triggered snapshot.
 */
import {
  classifyState,
  recordLifecycle,
  NEW_DAYS,
  STALE_DAYS,
  WITHDRAWN_DAYS,
  type LifecycleListing,
  type LifecycleCtx,
  type LifecycleState,
} from './listing-lifecycle.ts';

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)}` : ''}`);
}

const NOW = new Date('2026-08-24T12:00:00Z');
const DAY = 86_400_000;
const daysAgo = (n: number) => new Date(NOW.getTime() - n * DAY).toISOString();

// A listing that WAS just seen (last_seen == the source's freshest ingest) is "currently seen".
const FRESH_REF = new Date(NOW.getTime()); // source's most-recent ingest reference

/** ctx where a full rescan happened just now → gone-from-feed aging IS eligible. */
function ctxFull(refLastSeen = FRESH_REF): LifecycleCtx {
  return { sourceMaxLastSeen: refLastSeen, lastFullRescanAt: NOW, now: NOW };
}
/** ctx for an incremental-only source (no full rescan ever) → gone-from-feed SUPPRESSED. */
function ctxIncremental(refLastSeen = FRESH_REF): LifecycleCtx {
  return { sourceMaxLastSeen: refLastSeen, lastFullRescanAt: null, now: NOW };
}

function base(over: Partial<LifecycleListing> = {}): LifecycleListing {
  return {
    id: 1,
    source: 'selftest',
    source_id: 'X1',
    address: '1 Test Way, Testville',
    price: 500000,
    status: null,
    first_seen: daysAgo(90),
    last_seen: daysAgo(0), // seen in the latest ingest
    ...over,
  };
}

function testClassify() {
  console.log('classifyState — pure state model');

  // new: first_seen within NEW_DAYS AND currently seen
  check(
    classifyState(base({ first_seen: daysAgo(NEW_DAYS - 1), last_seen: daysAgo(0) }), ctxFull()) === 'new',
    'new: first_seen within NEW_DAYS and currently seen',
  );

  // active: currently seen but old first_seen
  check(
    classifyState(base({ first_seen: daysAgo(200), last_seen: daysAgo(0) }), ctxFull()) === 'active',
    'active: currently seen, not new',
  );

  // stale: gone from feed >= STALE_DAYS but < WITHDRAWN_DAYS (with a full rescan)
  check(
    classifyState(base({ last_seen: daysAgo(STALE_DAYS + 1) }), ctxFull()) === 'stale',
    'stale: gone from feed >= STALE_DAYS (< WITHDRAWN_DAYS)',
  );

  // withdrawn: gone from feed >= WITHDRAWN_DAYS (with a full rescan)
  check(
    classifyState(base({ last_seen: daysAgo(WITHDRAWN_DAYS + 5) }), ctxFull()) === 'withdrawn',
    'withdrawn: gone from feed >= WITHDRAWN_DAYS',
  );

  // closed: status transacted wins outright, even if currently seen
  check(
    classifyState(base({ status: 'Sold', last_seen: daysAgo(0) }), ctxFull()) === 'closed',
    'closed: status="Sold" wins over feed-age',
  );
  check(classifyState(base({ status: 'Closed' }), ctxFull()) === 'closed', 'closed: status="Closed"');

  // withdrawn by status string (currently seen but explicitly withdrawn/cancelled/expired)
  for (const s of ['Withdrawn', 'Cancelled', 'Canceled', 'Expired']) {
    check(
      classifyState(base({ status: s, last_seen: daysAgo(0) }), ctxFull()) === 'withdrawn',
      `withdrawn: status="${s}" wins over feed-age`,
    );
  }

  // fix-1 guard: gone from feed 60d BUT incremental-only source ⇒ NOT withdrawn (stays active).
  const goneOnIncremental = classifyState(base({ last_seen: daysAgo(60), first_seen: daysAgo(200) }), ctxIncremental());
  check(
    goneOnIncremental === 'active',
    'fix-1 guard: gone 60d on incremental-only source is NOT withdrawn (structural absence)',
    { got: goneOnIncremental },
  );

  // fix-1 guard: full rescan predates last_seen ⇒ still cannot prove gone ⇒ suppressed.
  const rescanBeforeLastSeen: LifecycleCtx = {
    sourceMaxLastSeen: FRESH_REF,
    lastFullRescanAt: new Date(NOW.getTime() - 90 * DAY), // rescan 90d ago, before last_seen 60d ago
    now: NOW,
  };
  check(
    classifyState(base({ last_seen: daysAgo(60), first_seen: daysAgo(200) }), rescanBeforeLastSeen) === 'active',
    'fix-1 guard: full rescan older than last_seen cannot prove gone ⇒ suppressed',
  );

  // fix-1 guard does NOT suppress an explicit withdrawn STATUS on an incremental source.
  check(
    classifyState(base({ status: 'Withdrawn', last_seen: daysAgo(60) }), ctxIncremental()) === 'withdrawn',
    'fix-1 guard: explicit withdrawn STATUS still classifies on incremental source',
  );
}

// ── in-memory fake db to drive recordLifecycle without Postgres ──────────────
interface EventRow { source: string; source_id: string; state: LifecycleState; observed_at: number }
interface SnapRow { source: string; source_id: string; captured_reason: string }
function makeFakeDb() {
  const events: EventRow[] = [];
  const snaps: SnapRow[] = [];
  let clock = 0;
  const query = async <T extends Record<string, any> = any>(text: string, params: unknown[] = []) => {
    const t = text.replace(/\s+/g, ' ').trim();
    // latest event for source/source_id
    if (t.startsWith('SELECT state FROM listing_lifecycle_event')) {
      const [source, source_id] = params as [string, string];
      const matching = events.filter((e) => e.source === source && e.source_id === source_id)
        .sort((a, b) => b.observed_at - a.observed_at);
      return { rows: (matching[0] ? [{ state: matching[0].state }] : []) as unknown as T[] };
    }
    // buildSnapshot's listing read — return a synthetic full row
    if (t.startsWith('SELECT l.*,')) {
      return { rows: [{ id: 1, address: '1 Test Way', price: 500000, broker_name: 'B', firm_name: 'F' }] as unknown as T[] };
    }
    if (t.startsWith('INSERT INTO listing_lifecycle_event')) {
      const [, source, source_id, , state] = params as any[];
      events.push({ source, source_id, state, observed_at: clock++ });
      return { rows: [] as T[] };
    }
    if (t.startsWith('INSERT INTO listing_snapshot')) {
      const [, source, source_id, , , captured_reason] = params as any[];
      snaps.push({ source, source_id, captured_reason });
      return { rows: [] as T[] };
    }
    throw new Error('fake db: unhandled query: ' + t.slice(0, 60));
  };
  return { query, events, snaps };
}

async function testRecord() {
  console.log('recordLifecycle — transitions + snapshot preservation (in-memory)');
  const db = makeFakeDb();
  const L = base();

  // 1. first-ever record on a currently-seen listing → 'new' event + a 'new' snapshot.
  const r1 = await recordLifecycle(db.query, base({ first_seen: daysAgo(2), last_seen: daysAgo(0) }), ctxFull());
  check(r1.state === 'new' && r1.eventInserted && r1.snapshotInserted, 'first record: new event + new snapshot', r1);

  // 2. same state again → NO-OP (append-only records only transitions).
  const r2 = await recordLifecycle(db.query, base({ first_seen: daysAgo(2), last_seen: daysAgo(0) }), ctxFull());
  check(!r2.eventInserted && !r2.snapshotInserted, 'unchanged state is a no-op (append-only)', r2);

  // 3. transition new→withdrawn (gone from feed) → event + a preserved WITHDRAWN snapshot.
  const r3 = await recordLifecycle(db.query, base({ last_seen: daysAgo(WITHDRAWN_DAYS + 5) }), ctxFull());
  check(
    r3.state === 'withdrawn' && r3.prev_state === 'new' && r3.eventInserted && r3.snapshotInserted,
    'transition → withdrawn preserves a full snapshot',
    r3,
  );
  check(
    db.snaps.some((s) => s.captured_reason === 'withdrawn'),
    'withdrawal snapshot recorded with captured_reason="withdrawn"',
  );

  // 4. withdrawn→seen-again → 'reappeared' event, but NO snapshot (coming back, not lost).
  const r4 = await recordLifecycle(db.query, base({ first_seen: daysAgo(200), last_seen: daysAgo(0) }), ctxFull());
  check(
    r4.state === 'reappeared' && r4.eventInserted && !r4.snapshotInserted,
    "reappeared: event written, NO snapshot (it's returning, not vanishing)",
    r4,
  );

  // 5. suppressSnapshot skips the state-triggered snapshot but still writes the event.
  const db2 = makeFakeDb();
  const r5 = await recordLifecycle(db2.query, base({ status: 'Sold', last_seen: daysAgo(0) }), ctxFull(), { suppressSnapshot: true });
  check(
    r5.state === 'closed' && r5.eventInserted && !r5.snapshotInserted && db2.snaps.length === 0,
    'suppressSnapshot: event written, snapshot skipped',
    r5,
  );
}

async function main() {
  console.log(`\nTK-10155 listing-lifecycle self-test (NEW_DAYS=${NEW_DAYS} STALE_DAYS=${STALE_DAYS} WITHDRAWN_DAYS=${WITHDRAWN_DAYS})\n`);
  testClassify();
  await testRecord();
  console.log(`\n${failed === 0 ? 'PASS — all lifecycle assertions held' : `FAIL — ${failed} assertion(s) failed`}\n`);
  process.exit(failed === 0 ? 0 : 1);
}

main().catch((e) => { console.error(e); process.exit(1); });