← 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); });