upleb.uk

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

summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-19 13:14:48 +0530
committerYour Name <you@example.com>2026-05-19 13:14:48 +0530
commitfe6aa9663d4cdabdc6e71db6068f8cd9e3739ffe (patch)
tree8cadb07243c07a6b3fa9453b239c9ac5cb02b454 /tests
parent77031f06a9a87320d011f501590985161d1eb305 (diff)
feat: WiFi beacon price discovery via Vendor IE (two-board verified)
Price discovery allows TollGate ESP32 boards to advertise their per-step price via WiFi Vendor-Specific Information Elements (OUI 0xC0FFEE) in beacon and probe response frames. Nearby boards passively scan and build a market view of competing TollGates without requiring internet access. Features: - beacon_price.c/h: 26-byte packed Vendor IE payload (price, step, metric, mint_hash, geohash, npub_hash), injected via esp_wifi_set_vendor_ie() - market.c/h: Passive WiFi scan receiver, vendor IE callback parsing, BSSID-correlated market entries, effective price ranking - GET /market API endpoint: JSON market snapshot with discovered entries - AP-only services: beacon + market + API start on WIFI_EVENT_AP_START, independent of STA connectivity - STA reconnect fix: 2s delay between retries creates scan windows; s_sta_connecting guard prevents double-connect - write-config-ap-only-a/b Makefile targets for STA-less testing - market_tick() in main loop, client price comparison logging Hardware verified: both boards discover each other via Vendor IE beacons. Board A sees TollGate-C0E9CA (RSSI=-30), Board B sees TollGate-B96D80 (RSSI=-25). test-market.mjs: 9/9, test-price-discovery.mjs: 7/7 per board. Unit tests: 45 new assertions across test_beacon_price (28) and test_market (17). All 15 test suites pass. ESP-IDF build clean for ESP32-S3.
Diffstat (limited to 'tests')
-rw-r--r--tests/integration/test-market.mjs60
-rw-r--r--tests/integration/test-price-discovery.mjs138
-rw-r--r--tests/unit/Makefile12
-rw-r--r--tests/unit/stubs/esp_wifi.h64
-rw-r--r--tests/unit/test_beacon_price.c132
-rw-r--r--tests/unit/test_market.c177
-rw-r--r--tests/unit/test_tollgate_client.c1
7 files changed, 581 insertions, 3 deletions
diff --git a/tests/integration/test-market.mjs b/tests/integration/test-market.mjs
new file mode 100644
index 0000000..20f062f
--- /dev/null
+++ b/tests/integration/test-market.mjs
@@ -0,0 +1,60 @@
1import { execSync } from 'child_process';
2
3const API_URL = `http://${process.env.TOLLGATE_IP || '10.192.45.1'}:2121`;
4
5function run(cmd) {
6 try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); }
7 catch (e) { return e.stdout || null; }
8}
9
10function runJson(cmd) {
11 const out = run(cmd);
12 try { return out ? JSON.parse(out) : null; }
13 catch { return null; }
14}
15
16let passed = 0, failed = 0;
17function assert(cond, msg) {
18 if (cond) { console.log(` PASS: ${msg}`); passed++; }
19 else { console.log(` FAIL: ${msg}`); failed++; }
20}
21
22console.log('=== test-market (GET /market) ===\n');
23
24console.log('--- /market endpoint responds ---');
25{
26 const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/market`);
27 assert(data !== null, '/market returns valid JSON');
28 assert(typeof data.count === 'number', `count is number (got ${data?.count})`);
29 assert(Array.isArray(data.entries), 'entries is array');
30}
31
32console.log('\n--- /market entry structure ---');
33{
34 const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/market`);
35 if (data && data.entries && data.entries.length > 0) {
36 const e = data.entries[0];
37 assert(typeof e.bssid === 'string', `bssid is string (got ${e.bssid})`);
38 assert(typeof e.ssid === 'string', `ssid is string (got ${e.ssid})`);
39 assert(typeof e.rssi === 'number', `rssi is number (got ${e.rssi})`);
40 assert(typeof e.price_per_step === 'number', `price_per_step is number (got ${e.price_per_step})`);
41 assert(typeof e.step_size === 'number', `step_size is number (got ${e.step_size})`);
42 assert(typeof e.metric === 'string', `metric is string (got ${e.metric})`);
43 } else {
44 console.log(' SKIP: no entries found (scan may not have run yet)');
45 }
46}
47
48console.log('\n--- /market with no discovered TollGates ---');
49{
50 const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/market`);
51 if (data && data.count === 0) {
52 assert(data.entries.length === 0, 'empty entries array when count=0');
53 console.log(' INFO: no nearby TollGates discovered yet (expected if only one board)');
54 } else if (data && data.count > 0) {
55 console.log(` INFO: ${data.count} nearby TollGate(s) discovered`);
56 }
57}
58
59console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
60process.exit(failed > 0 ? 1 : 0);
diff --git a/tests/integration/test-price-discovery.mjs b/tests/integration/test-price-discovery.mjs
new file mode 100644
index 0000000..6762130
--- /dev/null
+++ b/tests/integration/test-price-discovery.mjs
@@ -0,0 +1,138 @@
1import { execSync } from 'child_process';
2
3const BOARD_A_IP = process.env.TOLLGATE_IP || '10.185.47.1';
4const BOARD_B_IP = process.env.TOLLGATE_B_IP || process.env.TOLLGATE_IP_B || '10.192.45.1';
5const API_A = `http://${BOARD_A_IP}:2121`;
6const API_B = `http://${BOARD_B_IP}:2121`;
7
8function run(cmd) {
9 try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); }
10 catch (e) { return e.stdout || null; }
11}
12
13function runJson(cmd) {
14 const out = run(cmd);
15 try { return out ? JSON.parse(out) : null; }
16 catch { return null; }
17}
18
19let passed = 0, failed = 0;
20function assert(cond, msg) {
21 if (cond) { console.log(` PASS: ${msg}`); passed++; }
22 else { console.log(` FAIL: ${msg}`); failed++; }
23}
24
25function canReach(url) {
26 const result = run(`curl -s --connect-timeout 3 --max-time 5 -o /dev/null -w "%{http_code}" ${url}`);
27 return result && result.trim() !== '000' && result.trim() !== '';
28}
29
30console.log('=== test-price-discovery (two-board) ===\n');
31
32const reachA = canReach(`${API_A}/market`);
33const reachB = canReach(`${API_B}/market`);
34
35console.log(`Reachability: Board A=${reachA ? 'YES' : 'NO'}, Board B=${reachB ? 'YES' : 'NO'}\n`);
36
37if (!reachA && !reachB) {
38 console.log('FATAL: Neither board reachable. Check TOLLGATE_IP and TOLLGATE_B_IP');
39 process.exit(1);
40}
41
42console.log('--- Board A: market endpoint ---');
43{
44 if (reachA) {
45 const data = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_A}/market`);
46 assert(data !== null, 'Board A /market returns JSON');
47 assert(typeof data?.count === 'number', `Board A count is ${data?.count}`);
48 if (data && data.entries) {
49 console.log(` Board A sees ${data.count} nearby TollGate(s):`);
50 for (const e of data.entries) {
51 console.log(` ${e.ssid} (BSSID: ${e.bssid}) — ${e.price_per_step} sats/step, RSSI: ${e.rssi}`);
52 }
53 }
54 } else {
55 console.log(' SKIP: Board A not reachable');
56 }
57}
58
59console.log('\n--- Board B: market endpoint ---');
60{
61 if (reachB) {
62 const data = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_B}/market`);
63 assert(data !== null, 'Board B /market returns JSON');
64 assert(typeof data?.count === 'number', `Board B count is ${data?.count}`);
65 if (data && data.entries) {
66 console.log(` Board B sees ${data.count} nearby TollGate(s):`);
67 for (const e of data.entries) {
68 console.log(` ${e.ssid} (BSSID: ${e.bssid}) — ${e.price_per_step} sats/step, RSSI: ${e.rssi}`);
69 }
70 }
71 } else {
72 console.log(' SKIP: Board B not reachable');
73 }
74}
75
76console.log('\n--- Cross-discovery: Board A sees Board B ---');
77{
78 if (reachA) {
79 const mktA = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_A}/market`);
80 if (mktA && mktA.count > 0) {
81 const foundB = mktA.entries.some(e =>
82 e.ssid.startsWith('TollGate-') && e.bssid !== '' && e.price_per_step > 0
83 );
84 assert(foundB, `Board A discovered another TollGate (count=${mktA.count})`);
85 } else {
86 console.log(' INFO: Board A has 0 entries. Scan may need more time.');
87 }
88 } else {
89 console.log(' SKIP: Board A not reachable');
90 }
91}
92
93console.log('\n--- Cross-discovery: Board B sees Board A ---');
94{
95 if (reachB) {
96 const mktB = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_B}/market`);
97 if (mktB && mktB.count > 0) {
98 const foundA = mktB.entries.some(e =>
99 e.ssid.startsWith('TollGate-') && e.bssid !== '' && e.price_per_step > 0
100 );
101 assert(foundA, `Board B discovered another TollGate (count=${mktB.count})`);
102 } else {
103 console.log(' INFO: Board B has 0 entries. Scan may need more time.');
104 }
105 } else {
106 console.log(' SKIP: Board B not reachable');
107 }
108}
109
110console.log('\n--- Discovery data integrity ---');
111{
112 const boards = [];
113 if (reachA) {
114 const mktA = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_A}/market`);
115 if (mktA?.entries) boards.push({ name: 'A', data: mktA });
116 }
117 if (reachB) {
118 const mktB = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_B}/market`);
119 if (mktB?.entries) boards.push({ name: 'B', data: mktB });
120 }
121
122 for (const { name, data } of boards) {
123 for (const e of data.entries) {
124 assert(typeof e.price_per_step === 'number' && e.price_per_step > 0,
125 `Board ${name} entry has valid price (${e.price_per_step})`);
126 assert(typeof e.step_size === 'number' && e.step_size > 0,
127 `Board ${name} entry has valid step_size (${e.step_size})`);
128 assert(typeof e.metric === 'string' && e.metric.length > 0,
129 `Board ${name} entry has valid metric (${e.metric})`);
130 assert(typeof e.rssi === 'number',
131 `Board ${name} entry has valid RSSI (${e.rssi})`);
132 break;
133 }
134 }
135}
136
137console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
138process.exit(failed > 0 ? 1 : 0);
diff --git a/tests/unit/Makefile b/tests/unit/Makefile
index 6d13e4d..7bd3f1e 100644
--- a/tests/unit/Makefile
+++ b/tests/unit/Makefile
@@ -22,7 +22,7 @@ LDFLAGS := -lmbedcrypto -lcjson -lm
22 22
23SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o 23SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o
24 24
25TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout test_mcp_handler test_nip04 test_cvm_server test_display test_negentropy_adapter 25TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout test_mcp_handler test_nip04 test_cvm_server test_display test_negentropy_adapter test_beacon_price test_market
26 26
27.PHONY: all test clean $(TESTS) 27.PHONY: all test clean $(TESTS)
28 28
@@ -63,8 +63,8 @@ test_cashu: test_cashu.c $(REPO_ROOT)/main/cashu.c
63test_session: test_session.c $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c 63test_session: test_session.c $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c
64 $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c -o $@ $(LDFLAGS) 64 $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c -o $@ $(LDFLAGS)
65 65
66test_tollgate_client: test_tollgate_client.c 66test_tollgate_client: test_tollgate_client.c $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c
67 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) 67 $(CC) $(CFLAGS) -I $(REPO_ROOT)/main $< $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c -o $@ $(LDFLAGS)
68 68
69test_lnurl_pay: test_lnurl_pay.c 69test_lnurl_pay: test_lnurl_pay.c
70 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) 70 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS)
@@ -87,5 +87,11 @@ test_display: test_display.c
87test_negentropy_adapter: test_negentropy_adapter.c 87test_negentropy_adapter: test_negentropy_adapter.c
88 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) 88 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS)
89 89
90test_beacon_price: test_beacon_price.c $(REPO_ROOT)/main/beacon_price.c
91 $(CC) $(CFLAGS) -I $(REPO_ROOT)/main $< $(REPO_ROOT)/main/beacon_price.c -o $@ $(LDFLAGS)
92
93test_market: test_market.c $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c
94 $(CC) $(CFLAGS) -I $(REPO_ROOT)/main $< $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c -o $@ $(LDFLAGS)
95
90clean: 96clean:
91 rm -f $(TESTS) $(SECP256K1_OBJ) 97 rm -f $(TESTS) $(SECP256K1_OBJ)
diff --git a/tests/unit/stubs/esp_wifi.h b/tests/unit/stubs/esp_wifi.h
index 6aa5787..5eb14bf 100644
--- a/tests/unit/stubs/esp_wifi.h
+++ b/tests/unit/stubs/esp_wifi.h
@@ -2,6 +2,7 @@
2#define STUBS_ESP_WIFI_H 2#define STUBS_ESP_WIFI_H
3 3
4#include <stdint.h> 4#include <stdint.h>
5#include <stdbool.h>
5#include <string.h> 6#include <string.h>
6#include "esp_err.h" 7#include "esp_err.h"
7 8
@@ -37,4 +38,67 @@ static inline esp_err_t esp_wifi_set_config(int ifx, const wifi_config_t *cfg) {
37static inline esp_err_t esp_wifi_set_mode(uint8_t mode) { (void)mode; return ESP_OK; } 38static inline esp_err_t esp_wifi_set_mode(uint8_t mode) { (void)mode; return ESP_OK; }
38static inline esp_err_t esp_wifi_start(void) { return ESP_OK; } 39static inline esp_err_t esp_wifi_start(void) { return ESP_OK; }
39 40
41#define WIFI_VENDOR_IE_ELEMENT_ID 0xDD
42
43typedef enum {
44 WIFI_VND_IE_TYPE_BEACON,
45 WIFI_VND_IE_TYPE_PROBE_REQ,
46 WIFI_VND_IE_TYPE_PROBE_RESP,
47 WIFI_VND_IE_TYPE_ASSOC_REQ,
48 WIFI_VND_IE_TYPE_ASSOC_RESP,
49} wifi_vendor_ie_type_t;
50
51typedef enum {
52 WIFI_VND_IE_ID_0,
53 WIFI_VND_IE_ID_1,
54} wifi_vendor_ie_id_t;
55
56typedef struct {
57 uint8_t element_id;
58 uint8_t length;
59 uint8_t vendor_oui[3];
60 uint8_t vendor_oui_type;
61 uint8_t payload[0];
62} vendor_ie_data_t;
63
64typedef void (*esp_vendor_ie_cb_t)(void *ctx, wifi_vendor_ie_type_t type, const uint8_t sa[6], const vendor_ie_data_t *vnd_ie, int rssi);
65
66static inline esp_err_t esp_wifi_set_vendor_ie(bool enable, wifi_vendor_ie_type_t type, wifi_vendor_ie_id_t idx, const void *vnd_ie) { (void)enable; (void)type; (void)idx; (void)vnd_ie; return ESP_OK; }
67static inline esp_err_t esp_wifi_set_vendor_ie_cb(esp_vendor_ie_cb_t cb, void *ctx) { (void)cb; (void)ctx; return ESP_OK; }
68
69#define WIFI_SCAN_TYPE_PASSIVE 0
70
71typedef struct {
72 uint8_t bssid[6];
73 uint8_t ssid[33];
74 uint8_t primary;
75 int second;
76 int8_t rssi;
77 int authmode;
78} wifi_ap_record_t;
79
80typedef struct {
81 uint8_t *ssid;
82 uint8_t *bssid;
83 uint8_t channel;
84 bool show_hidden;
85 int scan_type;
86 union {
87 struct { int min; int max; } active;
88 int passive;
89 } scan_time;
90} wifi_scan_config_t;
91
92static inline esp_err_t esp_wifi_scan_start(const wifi_scan_config_t *cfg, bool block) { (void)cfg; (void)block; return ESP_OK; }
93static inline esp_err_t esp_wifi_scan_get_ap_num(uint16_t *n) { *n = 0; return ESP_OK; }
94static inline esp_err_t esp_wifi_scan_get_ap_records(uint16_t *n, wifi_ap_record_t *records) { (void)records; *n = 0; return ESP_OK; }
95
96#define WIFI_EVENT_SCAN_DONE 3
97
98typedef void *esp_event_handler_instance_t;
99typedef const char *esp_event_base_t;
100#define WIFI_EVENT "WIFI_EVENT"
101
102static inline esp_err_t esp_event_handler_instance_register(esp_event_base_t a, int32_t b, void *c, void *d, esp_event_handler_instance_t *e) { (void)a; (void)b; (void)c; (void)d; (void)e; return ESP_OK; }
103
40#endif 104#endif
diff --git a/tests/unit/test_beacon_price.c b/tests/unit/test_beacon_price.c
new file mode 100644
index 0000000..9574478
--- /dev/null
+++ b/tests/unit/test_beacon_price.c
@@ -0,0 +1,132 @@
1#include "test_framework.h"
2#include "../../main/config.h"
3#include "../../main/identity.h"
4#include <string.h>
5#include <stdio.h>
6#include <mbedtls/sha256.h>
7
8#include "../../main/beacon_price.h"
9
10static tollgate_config_t g_test_config;
11static tollgate_identity_t g_test_identity;
12
13const tollgate_config_t *tollgate_config_get(void) { return &g_test_config; }
14const tollgate_identity_t *identity_get(void) { return &g_test_identity; }
15
16int main(void)
17{
18 printf("=== test_beacon_price ===\n");
19
20 memset(&g_test_config, 0, sizeof(g_test_config));
21 strncpy(g_test_config.mint_url, "https://testnut.cashu.space", sizeof(g_test_config.mint_url) - 1);
22 strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1);
23 g_test_config.price_per_step = 21;
24 g_test_config.step_size_ms = 60000;
25 strncpy(g_test_config.nostr_geohash, "u281w0dfz", sizeof(g_test_config.nostr_geohash) - 1);
26
27 memset(&g_test_identity, 0, sizeof(g_test_identity));
28 strncpy(g_test_identity.npub_hex, "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", 64);
29 g_test_identity.initialized = true;
30
31 printf("\n--- tollgate_price_payload_t size ---\n");
32 {
33 ASSERT_EQ_INT(26, (int)TOLLGATE_IE_PAYLOAD_SIZE, "payload is 26 bytes");
34 ASSERT_EQ_INT(32, (int)TOLLGATE_IE_TOTAL_SIZE, "total IE is 32 bytes");
35 }
36
37 printf("\n--- beacon_price_hash_mint ---\n");
38 {
39 uint8_t hash[4];
40 beacon_price_hash_mint("https://testnut.cashu.space", hash);
41
42 uint8_t expected[32];
43 mbedtls_sha256((const unsigned char *)"https://testnut.cashu.space",
44 strlen("https://testnut.cashu.space"), expected, 0);
45 ASSERT_MEM_EQ(expected, hash, 4, "mint_hash matches SHA-256 prefix");
46
47 uint8_t hash2[4];
48 beacon_price_hash_mint("https://other.mint.url", hash2);
49 ASSERT(memcmp(hash, hash2, 4) != 0, "different mint URLs produce different hashes");
50 }
51
52 printf("\n--- beacon_price_hash_npub ---\n");
53 {
54 uint8_t hash[4];
55 beacon_price_hash_npub("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", hash);
56
57 uint8_t expected[32];
58 mbedtls_sha256((const unsigned char *)"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
59 64, expected, 0);
60 ASSERT_MEM_EQ(expected, hash, 4, "npub_hash matches SHA-256 prefix");
61 }
62
63 printf("\n--- beacon_price_build_ie (time metric) ---\n");
64 {
65 tollgate_price_ie_t ie;
66 beacon_price_build_ie(&ie);
67
68 ASSERT_EQ_INT(0xDD, ie.element_id, "element_id is 0xDD");
69 ASSERT_EQ_INT(4 + 26, ie.length, "length is 30 (4 header + 26 payload)");
70 ASSERT_EQ_INT(0xC0, ie.vendor_oui[0], "OUI byte 0");
71 ASSERT_EQ_INT(0xFF, ie.vendor_oui[1], "OUI byte 1");
72 ASSERT_EQ_INT(0xEE, ie.vendor_oui[2], "OUI byte 2");
73 ASSERT_EQ_INT(0x01, ie.vendor_oui_type, "OUI type is 0x01");
74
75 ASSERT_EQ_INT(1, ie.payload.version, "version is 1");
76 ASSERT_EQ_INT(0, ie.payload.metric, "metric is 0 (milliseconds)");
77 ASSERT_EQ_INT(21, ie.payload.price_per_step, "price is 21");
78 ASSERT_EQ_INT(60000, (int)ie.payload.step_size, "step_size is 60000");
79
80 uint8_t expected_mint_hash[4];
81 beacon_price_hash_mint("https://testnut.cashu.space", expected_mint_hash);
82 ASSERT_MEM_EQ(expected_mint_hash, ie.payload.mint_hash, 4, "mint_hash matches");
83
84 ASSERT_EQ_INT(9, ie.payload.geohash_len, "geohash_len is 9");
85 ASSERT(memcmp(ie.payload.geohash, "u281w0dfz", 9) == 0, "geohash matches");
86 }
87
88 printf("\n--- beacon_price_build_ie (bytes metric) ---\n");
89 {
90 strncpy(g_test_config.metric, "bytes", sizeof(g_test_config.metric) - 1);
91 g_test_config.step_size_bytes = 22020096;
92 g_test_config.price_per_step = 5;
93
94 tollgate_price_ie_t ie;
95 beacon_price_build_ie(&ie);
96
97 ASSERT_EQ_INT(1, ie.payload.metric, "metric is 1 (bytes)");
98 ASSERT_EQ_INT(5, ie.payload.price_per_step, "price is 5");
99 ASSERT_EQ_INT(22020096, (int)ie.payload.step_size, "step_size is 22020096 bytes");
100
101 strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1);
102 g_test_config.step_size_ms = 60000;
103 g_test_config.price_per_step = 21;
104 }
105
106 printf("\n--- roundtrip: build → parse ---\n");
107 {
108 tollgate_price_ie_t ie;
109 beacon_price_build_ie(&ie);
110
111 vendor_ie_data_t *vnd_ie = (vendor_ie_data_t *)&ie;
112
113 ASSERT(vnd_ie->length >= 4 + (int)TOLLGATE_IE_PAYLOAD_SIZE, "vendor IE length sufficient");
114
115 const tollgate_price_payload_t *parsed = (const tollgate_price_payload_t *)vnd_ie->payload;
116 ASSERT_EQ_INT(1, parsed->version, "parsed version");
117 ASSERT_EQ_INT(0, parsed->metric, "parsed metric");
118 ASSERT_EQ_INT(21, parsed->price_per_step, "parsed price");
119 ASSERT_EQ_INT(60000, (int)parsed->step_size, "parsed step_size");
120 ASSERT_EQ_INT(9, parsed->geohash_len, "parsed geohash_len");
121 }
122
123 printf("\n--- struct packing check ---\n");
124 {
125 tollgate_price_ie_t ie;
126 memset(&ie, 0, sizeof(ie));
127 int expected_size = 2 + 3 + 1 + 26;
128 ASSERT_EQ_INT(expected_size, (int)sizeof(tollgate_price_ie_t), "no padding in struct");
129 }
130
131 TEST_SUMMARY();
132}
diff --git a/tests/unit/test_market.c b/tests/unit/test_market.c
new file mode 100644
index 0000000..c19d26e
--- /dev/null
+++ b/tests/unit/test_market.c
@@ -0,0 +1,177 @@
1#include "test_framework.h"
2#include "../../main/beacon_price.h"
3#include "../../main/market.h"
4#include "../../main/config.h"
5#include "../../main/identity.h"
6#include <string.h>
7#include <stdio.h>
8#include <stdlib.h>
9
10static tollgate_config_t g_test_config;
11static tollgate_identity_t g_test_identity;
12
13const tollgate_config_t *tollgate_config_get(void) { return &g_test_config; }
14const tollgate_identity_t *identity_get(void) { return &g_test_identity; }
15
16static void build_test_ie(tollgate_price_ie_t *ie, uint16_t price, uint32_t step, uint8_t metric,
17 const char *geohash, const char *mint_url, const char *npub_hex)
18{
19 memset(ie, 0, sizeof(*ie));
20 ie->element_id = 0xDD;
21 ie->length = 4 + TOLLGATE_IE_PAYLOAD_SIZE;
22 ie->vendor_oui[0] = TOLLGATE_OUI_0;
23 ie->vendor_oui[1] = TOLLGATE_OUI_1;
24 ie->vendor_oui[2] = TOLLGATE_OUI_2;
25 ie->vendor_oui_type = TOLLGATE_IE_TYPE;
26
27 ie->payload.version = TOLLGATE_IE_VERSION;
28 ie->payload.metric = metric;
29 ie->payload.price_per_step = price;
30 ie->payload.step_size = step;
31
32 if (mint_url) beacon_price_hash_mint(mint_url, ie->payload.mint_hash);
33 if (npub_hex) beacon_price_hash_npub(npub_hex, ie->payload.npub_hash);
34
35 uint8_t gh_len = (uint8_t)strnlen(geohash, TOLLGATE_IE_GEOHASH_MAX);
36 ie->payload.geohash_len = gh_len;
37 memcpy(ie->payload.geohash, geohash, gh_len);
38}
39
40static void reset_market(void)
41{
42 market_t *m = (market_t *)market_get();
43 memset(m, 0, sizeof(*m));
44}
45
46int main(void)
47{
48 printf("=== test_market ===\n");
49
50 memset(&g_test_config, 0, sizeof(g_test_config));
51 g_test_config.market_enabled = true;
52 g_test_config.market_scan_interval_s = 30;
53 strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1);
54
55 memset(&g_test_identity, 0, sizeof(g_test_identity));
56 strncpy(g_test_identity.npub_hex, "0000000000000000000000000000000000000000000000000000000000000001", 64);
57 g_test_identity.initialized = true;
58
59 printf("\n--- parse vendor IE (valid) ---\n");
60 {
61 reset_market();
62 tollgate_price_ie_t ie;
63 build_test_ie(&ie, 21, 60000, 0, "u281w0dfz",
64 "https://testnut.cashu.space",
65 "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
66
67 uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x01};
68 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -45);
69
70 const market_t *m = market_get();
71 ASSERT_EQ_INT(1, m->count, "one entry added");
72 ASSERT(m->entries[0].valid, "entry is valid");
73 ASSERT_EQ_INT(21, m->entries[0].price_per_step, "price is 21");
74 ASSERT_EQ_INT(60000, (int)m->entries[0].step_size, "step_size is 60000");
75 ASSERT_EQ_INT(0, m->entries[0].metric, "metric is 0 (time)");
76 ASSERT_EQ_INT(-45, m->entries[0].rssi, "rssi is -45");
77 ASSERT(memcmp(m->entries[0].bssid, bssid, 6) == 0, "bssid matches");
78 }
79
80 printf("\n--- parse vendor IE (ignore self) ---\n");
81 {
82 reset_market();
83 tollgate_price_ie_t ie;
84 build_test_ie(&ie, 21, 60000, 0, "u281w0dfz",
85 "https://testnut.cashu.space",
86 g_test_identity.npub_hex);
87
88 uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x02};
89 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -50);
90
91 const market_t *m = market_get();
92 ASSERT_EQ_INT(0, m->count, "self-entry ignored");
93 }
94
95 printf("\n--- parse vendor IE (wrong OUI) ---\n");
96 {
97 reset_market();
98 tollgate_price_ie_t ie;
99 build_test_ie(&ie, 21, 60000, 0, "u281w0dfz",
100 "https://testnut.cashu.space",
101 "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
102 ie.vendor_oui[0] = 0x00;
103
104 uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x03};
105 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -40);
106
107 const market_t *m = market_get();
108 ASSERT_EQ_INT(0, m->count, "wrong OUI rejected");
109 }
110
111 printf("\n--- market_find_cheapest ---\n");
112 {
113 reset_market();
114
115 tollgate_price_ie_t ie1, ie2, ie3;
116 build_test_ie(&ie1, 21, 60000, 0, "u281w0dfz",
117 "https://testnut.cashu.space", "aaa...npub1");
118 build_test_ie(&ie2, 10, 60000, 0, "u281w0dfz",
119 "https://testnut.cashu.space", "bbb...npub2");
120 build_test_ie(&ie3, 50, 60000, 0, "u281w0dfz",
121 "https://testnut.cashu.space", "ccc...npub3");
122
123 uint8_t bssid1[6] = {0x01, 0x01, 0x01, 0x01, 0x01, 0x01};
124 uint8_t bssid2[6] = {0x02, 0x02, 0x02, 0x02, 0x02, 0x02};
125 uint8_t bssid3[6] = {0x03, 0x03, 0x03, 0x03, 0x03, 0x03};
126
127 market_parse_vendor_ie(bssid1, (vendor_ie_data_t *)&ie1, -45);
128 market_parse_vendor_ie(bssid2, (vendor_ie_data_t *)&ie2, -50);
129 market_parse_vendor_ie(bssid3, (vendor_ie_data_t *)&ie3, -55);
130
131 const market_t *m = market_get();
132 ASSERT_EQ_INT(3, m->count, "three entries");
133
134 strncpy((char *)m->entries[0].ssid, "TollGate-A", 32);
135 strncpy((char *)m->entries[1].ssid, "TollGate-B", 32);
136 strncpy((char *)m->entries[2].ssid, "TollGate-C", 32);
137
138 int cheapest = market_find_cheapest();
139 ASSERT(cheapest >= 0, "found a cheapest entry");
140 ASSERT_EQ_INT(10, m->entries[cheapest].price_per_step, "cheapest is 10 sats");
141 }
142
143 printf("\n--- update existing entry ---\n");
144 {
145 reset_market();
146 tollgate_price_ie_t ie;
147 build_test_ie(&ie, 21, 60000, 0, "u281w0dfz",
148 "https://testnut.cashu.space", "npub1");
149 uint8_t bssid[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06};
150
151 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -45);
152 ASSERT_EQ_INT(1, market_get()->count, "first add");
153
154 build_test_ie(&ie, 15, 60000, 0, "u281w0dfz",
155 "https://testnut.cashu.space", "npub1");
156 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -47);
157 ASSERT_EQ_INT(1, market_get()->count, "update doesn't increase count");
158 ASSERT_EQ_INT(15, market_get()->entries[0].price_per_step, "price updated to 15");
159 }
160
161 printf("\n--- geohash preserved ---\n");
162 {
163 reset_market();
164 tollgate_price_ie_t ie;
165 build_test_ie(&ie, 21, 60000, 0, "u281w0dfz",
166 "https://testnut.cashu.space", "npub1");
167 uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
168
169 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -40);
170
171 const market_t *m = market_get();
172 ASSERT(m->entries[0].valid, "entry valid");
173 ASSERT_EQ_STR("u281w0dfz", m->entries[0].geohash, "geohash is u281w0dfz");
174 }
175
176 TEST_SUMMARY();
177}
diff --git a/tests/unit/test_tollgate_client.c b/tests/unit/test_tollgate_client.c
index 686ad19..eebc747 100644
--- a/tests/unit/test_tollgate_client.c
+++ b/tests/unit/test_tollgate_client.c
@@ -13,6 +13,7 @@ const tollgate_config_t *tollgate_config_get(void) {
13 13
14uint64_t nucula_wallet_balance(void) { return 100; } 14uint64_t nucula_wallet_balance(void) { return 100; }
15esp_err_t nucula_wallet_send(uint64_t a, char *b, size_t c) { (void)a; (void)b; (void)c; return ESP_OK; } 15esp_err_t nucula_wallet_send(uint64_t a, char *b, size_t c) { (void)a; (void)b; (void)c; return ESP_OK; }
16const void *identity_get(void) { return NULL; }
16 17
17#include "freertos/FreeRTOS.h" 18#include "freertos/FreeRTOS.h"
18 19