upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/tests/integration/multi-mint.mjs
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-19 13:21:25 +0530
committerYour Name <you@example.com>2026-05-19 13:31:08 +0530
commiteeba74a4a1c011e85e33dea4252b381e35a64ea4 (patch)
tree14862e7d300511e28e214c743fd2f699bc54c5b8 /tests/integration/multi-mint.mjs
parentb0d9d494f00ee77f9efc22d1ef2ea3c94b23ddbd (diff)
feat: multi-mint wallet with health tracking, WPA auto-detect, display gating
Squash merge of feature/multi-mint-support (21 commits): Multi-mint wallet: - Accept payments from 4 mints: minibits, coinos, 21mint, lnvoltz - Periodic health probing (300s interval, 3 recovery threshold) - Multi-wallet init with nucula_wallet_init_multi() - /mints and /wallet API endpoints WPA auto-detect: - wifi_auth_mode config field (default WPA2, supports WPA3) - Runtime mapping to wifi_auth_mode_t in STA config Display gating: - display_enabled config field (default true) - Guards display_init/display_update per-board Bug fixes: - 3s delay before service start prevents lwip mem_free assertion - Real npub in discovery (identity_get()->npub_hex) - Health probe interval 300s (production value) - Duplicate services_start_task call removed - UTF-8 arrow replaced with ASCII in log message Tests: 61+14 unit tests passing, firmware builds clean
Diffstat (limited to 'tests/integration/multi-mint.mjs')
-rw-r--r--tests/integration/multi-mint.mjs193
1 files changed, 193 insertions, 0 deletions
diff --git a/tests/integration/multi-mint.mjs b/tests/integration/multi-mint.mjs
new file mode 100644
index 0000000..1b36aa0
--- /dev/null
+++ b/tests/integration/multi-mint.mjs
@@ -0,0 +1,193 @@
1import { execSync } from 'child_process';
2
3const IP = process.env.TOLLGATE_IP || '10.192.45.1';
4const API_PORT = 2121;
5const BASE = `http://${IP}:${API_PORT}`;
6const MINTS_EXPECTED = [
7 'https://mint.minibits.cash/Bitcoin',
8 'https://mint.coinos.io',
9 'https://21mint.me',
10 'https://mint.lnvoltz.com',
11];
12let passed = 0, failed = 0, skipped = 0;
13
14function assert(condition, test) {
15 if (condition) { console.log(` \u2713 ${test}`); passed++; }
16 else { console.log(` \u2717 ${test}`); failed++; }
17}
18function skip(test, reason) {
19 console.log(` \u25CB ${test} (SKIPPED: ${reason})`); skipped++;
20}
21function run(cmd) {
22 try { return execSync(cmd, { encoding: 'utf8', timeout: 30000 }); }
23 catch (e) { return e.stdout || null; }
24}
25function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
26
27console.log(`\n========================================`);
28console.log(` Multi-Mint Integration Test`);
29console.log(` Target: ${IP}:${API_PORT}`);
30console.log(`========================================\n`);
31
32// ===== Pre-flight: wait for board to be ready =====
33console.log('--- Pre-flight: Board Readiness ---');
34let discovery = null;
35for (let i = 0; i < 10; i++) {
36 const out = run(`curl -s --connect-timeout 3 ${BASE}/`);
37 if (out) { try { discovery = JSON.parse(out); } catch {} }
38 if (discovery) break;
39 if (i < 9) execSync('sleep 3');
40}
41if (!discovery) {
42 console.log(' FATAL: Board not responding after 10 retries. Aborting.');
43 process.exit(2);
44}
45console.log(' Board is responding!');
46
47// ===== BURST FETCH: grab everything in one go =====
48console.log(' Burst-fetching all endpoints...');
49
50const mintsRaw = run(`curl -s --connect-timeout 5 ${BASE}/mints`);
51const walletRaw = run(`curl -s --connect-timeout 5 ${BASE}/wallet`);
52const usageRaw = run(`curl -s --connect-timeout 5 ${BASE}/usage`);
53const whoamiRaw = run(`curl -s --connect-timeout 5 ${BASE}/whoami`);
54const portalRaw = run(`curl -s --connect-timeout 10 http://${IP}/`);
55
56const badTokenRaw = run(`curl -s --connect-timeout 5 -X POST -d "cashuAtest123" ${BASE}/`);
57const emptyBodyRaw = run(`curl -s --connect-timeout 5 -X POST -d "" ${BASE}/`);
58const noPrefixRaw = run(`curl -s --connect-timeout 5 -X POST -d "not_a_cashu_token" ${BASE}/`);
59
60const fakeV3Token = 'cashuA' + Buffer.from(JSON.stringify({
61 token: [{ mint: 'https://mint.minibits.cash/Bitcoin', proofs: [{ amount: 1, id: 'fake', secret: 'fake', C: 'fake' }] }]
62})).toString('base64url');
63const fakeTokenRaw = run(`curl -s --connect-timeout 5 -X POST -d "${fakeV3Token}" ${BASE}/`);
64
65const badMintToken = 'cashuA' + Buffer.from(JSON.stringify({
66 token: [{ mint: 'https://evil-mint.example.com', proofs: [{ amount: 1, id: 'fake', secret: 'fake', C: 'fake' }] }]
67})).toString('base64url');
68const badMintRaw = run(`curl -s --connect-timeout 5 -X POST -d "${badMintToken}" ${BASE}/`);
69
70let mints = null, wallet = null, usage = null;
71try { mints = mintsRaw ? JSON.parse(mintsRaw) : null; } catch {}
72try { wallet = walletRaw ? JSON.parse(walletRaw) : null; } catch {}
73try { usage = usageRaw ? JSON.parse(usageRaw) : null; } catch {}
74
75const boardHasInternet = mints && mints.some(m => m.reachable === true);
76
77console.log(` Got: discovery=${!!discovery} mints=${!!mints} wallet=${!!wallet} usage=${!!usage} whoami=${!!whoamiRaw} portal=${!!portalRaw}`);
78console.log('');
79
80// ===== SECTION 1: Configuration =====
81console.log('--- Section 1: Configuration ---');
82assert(discovery && discovery.kind === 10021, 'Discovery has kind=10021');
83assert(discovery && discovery.tags && discovery.tags.some(t => t[0] === 'metric' && t[1] === 'milliseconds'), 'Metric is milliseconds');
84const priceTag = discovery && discovery.tags && discovery.tags.find(t => t[0] === 'price_per_step');
85assert(priceTag && priceTag[1] === 'cashu', 'Price tag uses cashu unit');
86assert(priceTag && priceTag[2] === '1', 'Price is 1 sat');
87assert(priceTag && priceTag[5] === '1', 'Price step count is 1');
88
89// ===== SECTION 2: Mint List =====
90console.log('\n--- Section 2: Mint List ---');
91assert(mints !== null, 'GET /mints returns valid JSON');
92assert(Array.isArray(mints), '/mints returns an array');
93assert(mints && mints.length === MINTS_EXPECTED.length, `/mints has ${MINTS_EXPECTED.length} entries (got ${mints ? mints.length : 0})`);
94
95if (mints && mints.length > 0) {
96 for (const expectedUrl of MINTS_EXPECTED) {
97 const found = mints.find(m => m.url === expectedUrl);
98 assert(found !== undefined, `Mint list contains ${expectedUrl}`);
99 if (found) {
100 assert(typeof found.reachable === 'boolean', `${expectedUrl.split('//')[1]} has boolean reachable field`);
101 }
102 }
103}
104
105// ===== SECTION 3: Health Status =====
106console.log('\n--- Section 3: Health Status ---');
107if (!boardHasInternet) {
108 skip('Mint reachability probes', 'Board has no internet');
109 skip('Reachable mint transitions', 'Board has no internet');
110 if (mints && mints.length > 0) {
111 const allUnreachable = mints.every(m => m.reachable === false);
112 assert(allUnreachable, 'All mints show reachable=false without internet');
113 }
114} else {
115 const reachableMints = mints.filter(m => m.reachable);
116 console.log(` Reachable: ${reachableMints.length}/${mints.length}`);
117 assert(reachableMints.length > 0, `At least 1 mint is reachable`);
118 for (const m of reachableMints) console.log(` \u2713 ${m.url}`);
119 for (const m of mints.filter(m => !m.reachable)) console.log(` \u2717 ${m.url}`);
120}
121
122// ===== SECTION 4: Payment Routing =====
123console.log('\n--- Section 4: Payment Routing ---');
124assert(badTokenRaw !== null, 'POST / with bad token returns response');
125assert(badTokenRaw && badTokenRaw.includes('payment-error-invalid'), 'Bad token rejected with payment-error-invalid');
126assert(emptyBodyRaw && emptyBodyRaw.includes('payment-error-invalid'), 'Empty body rejected');
127assert(noPrefixRaw && noPrefixRaw.includes('payment-error-invalid'), 'Non-cashu body rejected');
128
129if (fakeTokenRaw) {
130 try {
131 const parsed = JSON.parse(fakeTokenRaw);
132 if (parsed.tags && parsed.tags.some(t => t[0] === 'code')) {
133 const code = parsed.tags.find(t => t[0] === 'code')[1];
134 if (boardHasInternet) {
135 assert(code === 'payment-error-verification' || code === 'payment-error-token-spent',
136 'Fake V3 token rejected by mint verification');
137 } else {
138 assert(code === 'payment-error-mint-not-accepted' || code === 'payment-error-verification',
139 'Fake V3 token rejected (unreachable or verification failed)');
140 }
141 } else { skip('Fake V3 token code check', 'Unexpected response format'); }
142 } catch { skip('Fake V3 token parse', 'Non-JSON response'); }
143}
144
145assert(badMintRaw && badMintRaw.includes('payment-error-mint-not-accepted'),
146 'Token from non-accepted mint rejected');
147
148// ===== SECTION 5: Wallet Status =====
149console.log('\n--- Section 5: Wallet Status ---');
150assert(wallet !== null, 'GET /wallet returns valid JSON');
151assert(wallet && typeof wallet.balance === 'number', 'Wallet has balance field');
152assert(wallet && typeof wallet.proof_count === 'number', 'Wallet has proof_count field');
153assert(wallet && Array.isArray(wallet.proofs), 'Wallet has proofs array');
154assert(wallet && wallet.balance >= 0, 'Balance is non-negative');
155assert(wallet && wallet.proof_count >= 0, 'Proof count is non-negative');
156
157// ===== SECTION 6: Session / Usage =====
158console.log('\n--- Section 6: Session / Usage ---');
159assert(usage !== null, 'GET /usage returns valid JSON');
160assert(whoamiRaw !== null, 'GET /whoami returns response');
161assert(whoamiRaw && whoamiRaw.includes('mac='), '/whoami returns mac=...');
162
163// ===== SECTION 7: Dynamic Mint Status =====
164console.log('\n--- Section 7: Dynamic Mint Status Transitions ---');
165if (!boardHasInternet) {
166 skip('Reachable->unreachable transition', 'No internet');
167 skip('Unreachable->reachable recovery', 'No internet');
168 skip('Mint status callback triggers', 'No internet');
169 skip('Payment rejection for unreachable mints', 'No internet');
170} else {
171 console.log(' Board has internet. Checking health probe results...');
172 console.log(' (Waiting 60s for probe cycle would exceed board uptime; skipping dynamic test)');
173 skip('Dynamic transition test', 'Board uptime too short for 300s probe interval');
174}
175
176// ===== SECTION 8: Portal Multi-Mint UI =====
177console.log('\n--- Section 8: Portal Multi-Mint UI ---');
178assert(portalRaw && portalRaw.includes('TollGate'), 'Portal HTML contains TollGate');
179assert(portalRaw && (portalRaw.includes('SUPPORTED MINTS') || portalRaw.includes('mint-list')), 'Portal has mint list section');
180
181for (const mintUrl of MINTS_EXPECTED) {
182 const shortUrl = mintUrl.replace('https://', '');
183 assert(portalRaw && portalRaw.includes(shortUrl), `Portal lists ${shortUrl}`);
184}
185
186assert(portalRaw && portalRaw.includes('mint-dot'), 'Portal has mint status dots');
187assert(portalRaw && portalRaw.includes(':2121/mints'), 'Portal JS fetches mints from API server');
188
189// ===== Summary =====
190console.log(`\n========================================`);
191console.log(` Results: ${passed} passed, ${failed} failed, ${skipped} skipped`);
192console.log(`========================================\n`);
193process.exit(failed > 0 ? 1 : 0);