|
| 1 | +/** |
| 2 | + * Sentinel Node Tester — On-Chain Report Encode/Decode Unit Tests |
| 3 | + * |
| 4 | + * Pure unit tests for core/onchain-report.js: resultToRecord, packBatch, |
| 5 | + * encodeBatch, decodeMemo. No server, no DB, no chain calls. |
| 6 | + * |
| 7 | + * NOTE: onchain-report.js imports chain.js and wallet.js. Neither of those |
| 8 | + * opens the DB or binds a port — they declare lazy async helpers. The import |
| 9 | + * is safe in a test context (verified by reading the files before writing this |
| 10 | + * test). If a future refactor adds module-scope side effects to those imports, |
| 11 | + * this test will fail at import time and that is by design — the failure will |
| 12 | + * surface the regression. |
| 13 | + * |
| 14 | + * NOTE (M7 / unsupported-version assertion): |
| 15 | + * The assertion on line ~120 ("SNTR1|v3|... decodes to null") tests the |
| 16 | + * behavior that the M7 fix is intended to guarantee. As of the current code, |
| 17 | + * _decodeCsv() already enforces `if (ver !== 'v2') return null` at line 197 |
| 18 | + * of onchain-report.js, so the unknown-version path in decodeMemo() (which |
| 19 | + * delegates to _decodeCsv) also returns null. The test therefore passes |
| 20 | + * RIGHT NOW. If a future change regresses this gate, the test will catch it. |
| 21 | + * The comment in decodeMemo()'s line-172 branch ("return null") vs the actual |
| 22 | + * call to _decodeCsv() is an M7 code-quality issue; the observable behaviour |
| 23 | + * is already correct. |
| 24 | + * |
| 25 | + * Run: node test/onchain-decode.test.js |
| 26 | + * Exit 0 = all pass, exit 1 = failures. |
| 27 | + */ |
| 28 | + |
| 29 | +const results = { pass: 0, fail: 0, errors: [] }; |
| 30 | + |
| 31 | +function assert(condition, name) { |
| 32 | + if (condition) { |
| 33 | + results.pass++; |
| 34 | + } else { |
| 35 | + results.fail++; |
| 36 | + results.errors.push(name); |
| 37 | + console.error(` FAIL: ${name}`); |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +function eq(a, b, name) { |
| 42 | + const ok = a === b; |
| 43 | + if (!ok) console.error(` FAIL ${name}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); |
| 44 | + assert(ok, name); |
| 45 | +} |
| 46 | + |
| 47 | +async function run() { |
| 48 | + console.log('On-Chain Report — Encode/Decode Unit Tests\n'); |
| 49 | + |
| 50 | + // ─── Import (pure encode/decode exports only) ────────────────────────────── |
| 51 | + console.log('1. Import core/onchain-report.js...'); |
| 52 | + let mod; |
| 53 | + try { |
| 54 | + mod = await import('../core/onchain-report.js'); |
| 55 | + assert(true, 'import core/onchain-report.js succeeds'); |
| 56 | + } catch (e) { |
| 57 | + assert(false, `import core/onchain-report.js: ${e.message.split('\n')[0]}`); |
| 58 | + console.error('Cannot continue without module. Aborting.'); |
| 59 | + process.exit(1); |
| 60 | + } |
| 61 | + |
| 62 | + const { resultToRecord, packBatch, encodeBatch, decodeMemo, SCHEMA, ERR_CODES } = mod; |
| 63 | + |
| 64 | + // ─── 2. SCHEMA constants ────────────────────────────────────────────────── |
| 65 | + console.log('2. SCHEMA constants...'); |
| 66 | + assert(typeof SCHEMA === 'object' && SCHEMA !== null, 'SCHEMA is an object'); |
| 67 | + eq(SCHEMA.MAGIC, 'SNTR1', 'SCHEMA.MAGIC = "SNTR1"'); |
| 68 | + eq(SCHEMA.VERSION, 2, 'SCHEMA.VERSION = 2'); |
| 69 | + eq(SCHEMA.MAX_RECORDS, 6, 'SCHEMA.MAX_RECORDS = 6 (chain memo cap)'); |
| 70 | + eq(SCHEMA.MEMO_CHAR_LIMIT, 256, 'SCHEMA.MEMO_CHAR_LIMIT = 256'); |
| 71 | + assert(typeof ERR_CODES === 'object', 'ERR_CODES is an object'); |
| 72 | + assert(ERR_CODES.OK === 0, 'ERR_CODES.OK = 0'); |
| 73 | + |
| 74 | + // ─── 3. resultToRecord — valid passing node ─────────────────────────────── |
| 75 | + console.log('3. resultToRecord — valid passing node...'); |
| 76 | + const passResult = { |
| 77 | + address: 'sentnode1abcdefghijklmnopqrstuvwxyz1234567890', |
| 78 | + actualMbps: 42.5, |
| 79 | + peers: 7, |
| 80 | + diag: { handshakeLatencyMs: 123 }, |
| 81 | + }; |
| 82 | + const passRec = resultToRecord(passResult); |
| 83 | + assert(passRec !== null, 'resultToRecord: passing node → non-null'); |
| 84 | + eq(passRec.ok, 1, 'resultToRecord: passing node → ok=1'); |
| 85 | + eq(passRec.mbps, 42.5, 'resultToRecord: passing node → mbps=42.5'); |
| 86 | + eq(passRec.peers, 7, 'resultToRecord: passing node → peers=7'); |
| 87 | + eq(passRec.lat, 123, 'resultToRecord: passing node → lat=123'); |
| 88 | + eq(passRec.address, passResult.address, 'resultToRecord: address preserved'); |
| 89 | + |
| 90 | + // ─── 4. resultToRecord — failed node (actualMbps=null) ─────────────────── |
| 91 | + console.log('4. resultToRecord — failed node...'); |
| 92 | + const failResult = { |
| 93 | + address: 'sentnode1abcdefghijklmnopqrstuvwxyz1234567890', |
| 94 | + actualMbps: null, |
| 95 | + peers: 0, |
| 96 | + diag: {}, |
| 97 | + }; |
| 98 | + const failRec = resultToRecord(failResult); |
| 99 | + assert(failRec !== null, 'resultToRecord: failed node → non-null'); |
| 100 | + eq(failRec.ok, 0, 'resultToRecord: failed node → ok=0'); |
| 101 | + assert(failRec.mbps === null, 'resultToRecord: failed node → mbps=null'); |
| 102 | + |
| 103 | + // ─── 5. resultToRecord — rejects invalid addresses ─────────────────────── |
| 104 | + console.log('5. resultToRecord — address validation...'); |
| 105 | + assert(resultToRecord({ address: null }) === null, 'resultToRecord: null address → null'); |
| 106 | + assert(resultToRecord({ address: 'sent1notanode' }) === null, 'resultToRecord: sent1 prefix → null'); |
| 107 | + assert(resultToRecord({ address: 'sentnode1short' }) === null, 'resultToRecord: too-short sentnode addr → null'); |
| 108 | + assert(resultToRecord({ address: '' }) === null, 'resultToRecord: empty address → null'); |
| 109 | + |
| 110 | + // ─── 6. packBatch — round-trip encode then decode ───────────────────────── |
| 111 | + console.log('6. packBatch → decodeMemo round-trip...'); |
| 112 | + const ctx = { |
| 113 | + region: 'US', |
| 114 | + baselineMbps: 100, |
| 115 | + startedAt: new Date('2026-01-15T12:00:00Z'), |
| 116 | + }; |
| 117 | + const records = [ |
| 118 | + { address: 'sentnode1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ok: 1, mbps: 42.5, peers: 3, lat: 120 }, |
| 119 | + { address: 'sentnode1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ok: 0, mbps: null, peers: 0, lat: 0 }, |
| 120 | + ]; |
| 121 | + const { memo, packed } = packBatch(ctx, records); |
| 122 | + assert(typeof memo === 'string', 'packBatch: returns memo string'); |
| 123 | + eq(packed, 2, 'packBatch: packed=2 records'); |
| 124 | + assert(memo.length <= 256, `packBatch: memo fits in 256 chars (got ${memo.length})`); |
| 125 | + assert(memo.startsWith('SNTR1|v2|'), 'packBatch: memo starts with SNTR1|v2|'); |
| 126 | + |
| 127 | + const decoded = decodeMemo(memo); |
| 128 | + assert(decoded !== null, 'decodeMemo: round-trip decoded successfully'); |
| 129 | + eq(decoded.version, 'v2', 'decodeMemo: version=v2'); |
| 130 | + eq(decoded.region, 'US', 'decodeMemo: region=US'); |
| 131 | + assert(Math.abs(decoded.baselineMbps - 100) < 0.1, 'decodeMemo: baselineMbps≈100'); |
| 132 | + eq(decoded.count, 2, 'decodeMemo: count=2 records'); |
| 133 | + |
| 134 | + const r0 = decoded.records[0]; |
| 135 | + assert(r0 !== undefined, 'decodeMemo: record[0] exists'); |
| 136 | + eq(r0.ok, true, 'decodeMemo: record[0].ok=true'); |
| 137 | + assert(Math.abs((r0.mbps ?? 0) - 42.5) < 0.1, 'decodeMemo: record[0].mbps≈42.5'); |
| 138 | + eq(r0.peers, 3, 'decodeMemo: record[0].peers=3'); |
| 139 | + |
| 140 | + const r1 = decoded.records[1]; |
| 141 | + assert(r1 !== undefined, 'decodeMemo: record[1] exists'); |
| 142 | + eq(r1.ok, false, 'decodeMemo: record[1].ok=false'); |
| 143 | + assert(r1.mbps === null, 'decodeMemo: record[1].mbps=null (failed node)'); |
| 144 | + |
| 145 | + // ─── 7. encodeBatch — accepts 1..6 records ─────────────────────────────── |
| 146 | + console.log('7. encodeBatch — batch size limits...'); |
| 147 | + const singleRecord = [{ address: 'sentnode1ccccccccccccccccccccccccccccccccccccccc', ok: 1, mbps: 10, peers: 1, lat: 50 }]; |
| 148 | + const singleMemo = encodeBatch(ctx, singleRecord); |
| 149 | + assert(typeof singleMemo === 'string' && singleMemo.length > 0, 'encodeBatch: 1 record → non-empty string'); |
| 150 | + assert(singleMemo.length <= 256, 'encodeBatch: 1 record fits in 256 chars'); |
| 151 | + |
| 152 | + // encodeBatch must throw if > MAX_RECORDS (6) records are passed |
| 153 | + const tooMany = Array.from({ length: 7 }, (_, i) => ({ |
| 154 | + address: `sentnode1${'x'.repeat(38)}${i}`, |
| 155 | + ok: 1, mbps: 10, peers: 0, lat: 0, |
| 156 | + })); |
| 157 | + let threw7 = false; |
| 158 | + try { encodeBatch(ctx, tooMany); } catch { threw7 = true; } |
| 159 | + assert(threw7, 'encodeBatch: throws when >6 records passed'); |
| 160 | + |
| 161 | + // encodeBatch must throw on empty array |
| 162 | + let threwEmpty = false; |
| 163 | + try { encodeBatch(ctx, []); } catch { threwEmpty = true; } |
| 164 | + assert(threwEmpty, 'encodeBatch: throws on empty records array'); |
| 165 | + |
| 166 | + // ─── 8. decodeMemo — null/empty/garbage inputs ─────────────────────────── |
| 167 | + console.log('8. decodeMemo — null/garbage inputs → null...'); |
| 168 | + assert(decodeMemo(null) === null, 'decodeMemo: null → null'); |
| 169 | + assert(decodeMemo('') === null, 'decodeMemo: empty string → null'); |
| 170 | + assert(decodeMemo('not-a-sentinel-memo') === null, 'decodeMemo: garbage → null'); |
| 171 | + assert(decodeMemo('SNTR1|v2') === null || decodeMemo('SNTR1|v2') !== undefined, |
| 172 | + 'decodeMemo: truncated header does not throw'); |
| 173 | + |
| 174 | + // ─── 9. decodeMemo — unsupported version returns null (M7 gate) ────────── |
| 175 | + // Per CLAUDE.md: a memo with an unsupported version header (e.g. SNTR1|v3|…) |
| 176 | + // MUST decode to null. The gate lives in _decodeCsv() at the `if (ver !== 'v2')` |
| 177 | + // check. decodeMemo() calls _decodeCsv() for any SNTR1|v<N>| prefix, and |
| 178 | + // _decodeCsv() enforces the version guard before touching records, so a v3 |
| 179 | + // header is refused cleanly. This test is NOT contingent on a future integration |
| 180 | + // — the behaviour is already correct in the current code. |
| 181 | + console.log('9. decodeMemo — unsupported version header → null (M7 gate)...'); |
| 182 | + const unsupportedV3 = 'SNTR1|v3|US|b=100.0|t=1700000000\nsentnode1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa|1|42.5|3|120'; |
| 183 | + const v3Result = decodeMemo(unsupportedV3); |
| 184 | + assert( |
| 185 | + v3Result === null, |
| 186 | + 'decodeMemo: SNTR1|v3|… header → null (unknown version must not be decoded)', |
| 187 | + ); |
| 188 | + |
| 189 | + // A future v99 header also must be refused |
| 190 | + const unsupportedV99 = 'SNTR1|v99|DE|b=50.0|t=1700000000'; |
| 191 | + assert( |
| 192 | + decodeMemo(unsupportedV99) === null, |
| 193 | + 'decodeMemo: SNTR1|v99|… header → null', |
| 194 | + ); |
| 195 | + |
| 196 | + // v2 still works after rejecting others (no side effects from rejection) |
| 197 | + const v2After = decodeMemo(memo); |
| 198 | + assert(v2After !== null, 'decodeMemo: v2 still decodes correctly after unsupported-version rejections'); |
| 199 | + |
| 200 | + // ─── 10. MEMO_CHAR_LIMIT enforcement ───────────────────────────────────── |
| 201 | + console.log('10. Memo stays within 256-char chain limit for any valid batch...'); |
| 202 | + // Build 6 records with reasonably-sized sentnode1 addresses |
| 203 | + const sixRecords = Array.from({ length: 6 }, (_, i) => ({ |
| 204 | + address: `sentnode1${'a'.repeat(30)}${String(i).padStart(8, '0')}`, |
| 205 | + ok: i % 2 === 0 ? 1 : 0, |
| 206 | + mbps: i % 2 === 0 ? 99.9 : null, |
| 207 | + peers: i * 3, |
| 208 | + lat: i * 50, |
| 209 | + })); |
| 210 | + const { memo: bigMemo, packed: bigPacked } = packBatch(ctx, sixRecords); |
| 211 | + assert(bigMemo.length <= 256, `packBatch(6 records): memo ≤ 256 chars (got ${bigMemo.length})`); |
| 212 | + assert(bigPacked >= 1, `packBatch(6 records): at least 1 record packed (got ${bigPacked})`); |
| 213 | + |
| 214 | + // ─── Results ────────────────────────────────────────────────────────────── |
| 215 | + console.log(`\n${'='.repeat(50)}`); |
| 216 | + console.log(`RESULTS: ${results.pass} passed, ${results.fail} failed (${results.pass + results.fail} total)`); |
| 217 | + if (results.errors.length > 0) { |
| 218 | + console.log('\nFAILURES:'); |
| 219 | + for (const e of results.errors) console.log(` FAIL: ${e}`); |
| 220 | + } |
| 221 | + console.log(`${'='.repeat(50)}`); |
| 222 | + process.exit(results.fail > 0 ? 1 : 0); |
| 223 | +} |
| 224 | + |
| 225 | +run().catch(e => { console.error('FATAL:', e.stack || e); process.exit(1); }); |
0 commit comments