|
| 1 | +/** |
| 2 | + * Resume — In-Memory Results Restoration (root-cause regression test) |
| 3 | + * |
| 4 | + * Bug: on resume, the continuous loop called pipeline.runAudit(resume=false), |
| 5 | + * which wipes the in-memory `results` array (pipeline.js: `results.length = 0`) |
| 6 | + * and sets totalNodes to the *remainder* only. Since /live's snapshot mirrors |
| 7 | + * getResults(), an already-tested batch vanished from /live on resume — logs |
| 8 | + * kept streaming but the node table went empty. Admin (DB-backed) stayed |
| 9 | + * complete, so the two desynced. |
| 10 | + * |
| 11 | + * Fix: continuous resume now (a) threads resume=true so the pipeline preserves |
| 12 | + * the results array + computes totalNodes = already-tested + remaining, and |
| 13 | + * (b) for the cross-process case (server restart → empty array) re-seeds the |
| 14 | + * in-memory results from the persisted batch_results via pipeline.seedResults(). |
| 15 | + * |
| 16 | + * This test covers the seed primitive + dedup semantics directly (no chain/ |
| 17 | + * wallet needed) and proves a DB-backed restore round-trips into getResults(). |
| 18 | + * |
| 19 | + * Run: node test/resume-results-seed.test.js |
| 20 | + */ |
| 21 | + |
| 22 | +import Database from 'better-sqlite3'; |
| 23 | + |
| 24 | +const out = { pass: 0, fail: 0, errors: [] }; |
| 25 | +function ok(cond, name) { |
| 26 | + if (cond) { out.pass++; console.log(` PASS ${name}`); } |
| 27 | + else { out.fail++; out.errors.push(name); console.log(` FAIL ${name}`); } |
| 28 | +} |
| 29 | + |
| 30 | +async function main() { |
| 31 | + process.env.NODE_ENV = 'test'; |
| 32 | + |
| 33 | + console.log('\nResume — In-Memory Results Restoration\n'); |
| 34 | + |
| 35 | + // ─── 1. pipeline.seedResults — append + dedup by address ────────────── |
| 36 | + console.log('[1] pipeline.seedResults appends and dedups by address'); |
| 37 | + const pipeline = await import('../audit/pipeline.js'); |
| 38 | + // Start from a clean in-memory results array. |
| 39 | + pipeline.getResults().length = 0; |
| 40 | + |
| 41 | + const added1 = pipeline.seedResults([ |
| 42 | + { address: 'sentnode1aaa', actualMbps: 12.5, errorCode: null }, |
| 43 | + { address: 'sentnode1bbb', actualMbps: null, errorCode: 'HANDSHAKE_FAIL' }, |
| 44 | + ]); |
| 45 | + ok(added1 === 2, `first seed appends 2 (got ${added1})`); |
| 46 | + ok(pipeline.getResults().length === 2, `getResults() has 2 rows (got ${pipeline.getResults().length})`); |
| 47 | + |
| 48 | + // Re-seeding the same address must NOT duplicate — it replaces in place. |
| 49 | + const added2 = pipeline.seedResults([ |
| 50 | + { address: 'sentnode1aaa', actualMbps: 99.9, errorCode: null }, |
| 51 | + { address: 'sentnode1ccc', actualMbps: 5.0, errorCode: null }, |
| 52 | + ]); |
| 53 | + ok(added2 === 1, `second seed appends only the new address (got ${added2})`); |
| 54 | + ok(pipeline.getResults().length === 3, `getResults() has 3 distinct rows (got ${pipeline.getResults().length})`); |
| 55 | + const aaa = pipeline.getResults().find(r => r.address === 'sentnode1aaa'); |
| 56 | + ok(aaa && aaa.actualMbps === 99.9, `re-seeded address is replaced, not duplicated (mbps=${aaa?.actualMbps})`); |
| 57 | + |
| 58 | + // Guards: non-array → 0, rows without address skipped. |
| 59 | + ok(pipeline.seedResults(null) === 0, 'seedResults(null) → 0'); |
| 60 | + ok(pipeline.seedResults([{ moniker: 'no-addr' }]) === 0, 'rows without address are skipped'); |
| 61 | + ok(pipeline.getResults().length === 3, 'guarded calls do not grow the array'); |
| 62 | + |
| 63 | + // ─── 2. DB round-trip: batch_results → getBatchResults → seedResults ── |
| 64 | + console.log('[2] persisted batch_results restore into getResults()'); |
| 65 | + const { useDb, getDb, insertBatch, insertBatchResult, getBatchResults } = |
| 66 | + await import('../core/db.js'); |
| 67 | + useDb(getDb(':memory:')); |
| 68 | + |
| 69 | + const batchId = insertBatch({ |
| 70 | + started_at: Date.now(), |
| 71 | + snapshot_size: 3, |
| 72 | + mode: 'p2p', |
| 73 | + snapshot_addresses: ['sentnode1xxx', 'sentnode1yyy', 'sentnode1zzz'], |
| 74 | + }, 'real'); |
| 75 | + ok(batchId > 0, `insertBatch returned id (got ${batchId})`); |
| 76 | + |
| 77 | + // Two nodes already tested before the (simulated) restart. |
| 78 | + insertBatchResult(batchId, { |
| 79 | + address: 'sentnode1xxx', type: 'wireguard', moniker: 'Alpha', |
| 80 | + country: 'US', countryCode: 'US', city: 'NYC', |
| 81 | + actualMbps: 22.4, peers: 3, maxPeers: 10, |
| 82 | + error: null, errorCode: null, baselineMbps: 18.0, testedAt: Date.now(), |
| 83 | + }, 'real'); |
| 84 | + insertBatchResult(batchId, { |
| 85 | + address: 'sentnode1yyy', type: 'v2ray', moniker: 'Bravo', |
| 86 | + country: 'DE', countryCode: 'DE', city: 'Berlin', |
| 87 | + actualMbps: null, peers: null, maxPeers: null, |
| 88 | + error: 'tunnel timeout', errorCode: 'TUNNEL_TIMEOUT', baselineMbps: 18.0, testedAt: Date.now(), |
| 89 | + }, 'real'); |
| 90 | + |
| 91 | + const { results: rows } = getBatchResults(batchId, { limit: 1000 }, 'real'); |
| 92 | + ok(rows.length === 2, `getBatchResults returns the 2 persisted rows (got ${rows.length})`); |
| 93 | + |
| 94 | + // Map snake_case DB rows → result shape (mirrors continuous._batchRowToResult) |
| 95 | + const mapped = rows.map(row => ({ |
| 96 | + address: row.node_address, |
| 97 | + moniker: row.moniker || '', |
| 98 | + country: row.country || '', |
| 99 | + countryCode: row.country_code || '', |
| 100 | + city: row.city || '', |
| 101 | + type: row.type || null, |
| 102 | + actualMbps: row.actual_mbps, |
| 103 | + baselineAtTest: row.baseline_mbps, |
| 104 | + peers: row.peers, |
| 105 | + maxPeers: row.max_peers, |
| 106 | + error: row.error || null, |
| 107 | + errorCode: row.error_code || null, |
| 108 | + skipped: row.error_code === 'TEST_RUN_SKIP', |
| 109 | + testedAt: row.tested_at, |
| 110 | + })); |
| 111 | + |
| 112 | + // Fresh in-memory array (simulates the empty array after a server restart). |
| 113 | + pipeline.getResults().length = 0; |
| 114 | + const restored = pipeline.seedResults(mapped); |
| 115 | + ok(restored === 2, `restored 2 already-tested nodes into getResults() (got ${restored})`); |
| 116 | + |
| 117 | + const res = pipeline.getResults(); |
| 118 | + const xxx = res.find(r => r.address === 'sentnode1xxx'); |
| 119 | + const yyy = res.find(r => r.address === 'sentnode1yyy'); |
| 120 | + ok(xxx && xxx.actualMbps === 22.4 && xxx.type === 'wireguard', |
| 121 | + 'restored passing node carries mbps + transport'); |
| 122 | + ok(yyy && yyy.errorCode === 'TUNNEL_TIMEOUT' && yyy.actualMbps == null, |
| 123 | + 'restored failed node carries error code + null mbps'); |
| 124 | + |
| 125 | + // Cleanup so we don't leak rows into any later in-process consumer. |
| 126 | + pipeline.getResults().length = 0; |
| 127 | + |
| 128 | + console.log(`\n${'='.repeat(60)}\nRESULTS: ${out.pass} passed, ${out.fail} failed (${out.pass + out.fail} total)`); |
| 129 | + if (out.errors.length) for (const e of out.errors) console.log(` FAIL: ${e}`); |
| 130 | + console.log('='.repeat(60)); |
| 131 | + process.exit(out.fail ? 1 : 0); |
| 132 | +} |
| 133 | + |
| 134 | +main().catch(e => { console.error('FATAL:', e.stack || e); process.exit(1); }); |
0 commit comments