Skip to content

Commit 196c83d

Browse files
kevincodex1OpenClaude
andcommitted
test: cover bootstrap merge logic with unit tests
Refactor bootstrap.rs into pure functions (parse_seed_list, merge_into_vecs) so the parse + merge logic can be tested without constructing a Config or mutating process-global env vars. Adds 11 tests covering: - valid v1 list parses - unknown version is rejected - malformed JSON is rejected - empty / missing peers array - merge appends new http + p2p entries - merge dedupes against existing entries - invalid p2p_multiaddr is skipped (http still added) - empty strings are skipped - null optional fields are tolerated - the canonical bootstrap-peers.json shipped in the repo always parses (regression guard against future schema changes) Co-Authored-By: OpenClaude <openclaude@gitlawb.com>
1 parent 795a101 commit 196c83d

1 file changed

Lines changed: 242 additions & 37 deletions

File tree

crates/gitlawb-node/src/bootstrap.rs

Lines changed: 242 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ use tracing::{info, warn};
1818
use crate::config::Config;
1919

2020
const EMBEDDED_PEERS_JSON: &str = include_str!("../../../bootstrap-peers.json");
21+
const SUPPORTED_VERSION: u32 = 1;
2122

2223
#[derive(Debug, Deserialize)]
2324
struct BootstrapList {
2425
version: u32,
26+
#[serde(default)]
2527
peers: Vec<BootstrapPeer>,
2628
}
2729

@@ -38,57 +40,58 @@ struct BootstrapPeer {
3840
added: Option<String>,
3941
}
4042

41-
/// Merge the embedded seed list into the runtime config.
42-
///
43-
/// - Appends any `http_url` to `config.bootstrap_peers` (used by gossip_task)
44-
/// - Appends any valid `p2p_multiaddr` to `config.p2p_bootstrap` (used by libp2p)
45-
/// - Dedupes against entries already present (env / CLI takes precedence)
46-
/// - No-op when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value
47-
pub fn merge_seeds(config: &mut Config) {
48-
if std::env::var("GITLAWB_BOOTSTRAP_DISABLE_SEEDS")
43+
/// Counts of newly-added entries returned by `merge_into_vecs`.
44+
#[derive(Debug, Default, PartialEq, Eq)]
45+
struct MergeCounts {
46+
http: usize,
47+
p2p: usize,
48+
}
49+
50+
/// Returns true when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value.
51+
fn seeds_disabled() -> bool {
52+
std::env::var("GITLAWB_BOOTSTRAP_DISABLE_SEEDS")
4953
.ok()
5054
.filter(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
5155
.is_some()
52-
{
53-
info!("bootstrap seed list disabled via GITLAWB_BOOTSTRAP_DISABLE_SEEDS");
54-
return;
55-
}
56-
57-
let list: BootstrapList = match serde_json::from_str(EMBEDDED_PEERS_JSON) {
58-
Ok(l) => l,
59-
Err(e) => {
60-
warn!(err = %e, "failed to parse embedded bootstrap-peers.json — skipping");
61-
return;
62-
}
63-
};
56+
}
6457

65-
if list.version != 1 {
66-
warn!(
67-
version = list.version,
68-
"unknown bootstrap-peers.json version — skipping"
69-
);
70-
return;
58+
/// Parse the seed list from a JSON string, rejecting unsupported versions.
59+
fn parse_seed_list(json: &str) -> Result<BootstrapList, String> {
60+
let list: BootstrapList = serde_json::from_str(json).map_err(|e| e.to_string())?;
61+
if list.version != SUPPORTED_VERSION {
62+
return Err(format!(
63+
"unsupported bootstrap-peers.json version: {} (expected {})",
64+
list.version, SUPPORTED_VERSION
65+
));
7166
}
67+
Ok(list)
68+
}
7269

73-
let mut added_http = 0;
74-
let mut added_p2p = 0;
70+
/// Pure merge: appends entries from `list` to the two vectors, deduping.
71+
/// Returns counts of entries actually added (i.e. not already present).
72+
fn merge_into_vecs(
73+
list: BootstrapList,
74+
http_peers: &mut Vec<String>,
75+
p2p_bootstrap: &mut Vec<String>,
76+
) -> MergeCounts {
77+
let mut counts = MergeCounts::default();
7578

7679
for peer in list.peers {
7780
if let Some(url) = peer
7881
.http_url
7982
.as_ref()
80-
.filter(|u| !u.is_empty() && !config.bootstrap_peers.contains(u))
83+
.filter(|u| !u.is_empty() && !http_peers.contains(u))
8184
{
82-
config.bootstrap_peers.push(url.clone());
83-
added_http += 1;
85+
http_peers.push(url.clone());
86+
counts.http += 1;
8487
}
8588

8689
if let Some(addr_str) = peer.p2p_multiaddr.as_ref().filter(|s| !s.is_empty()) {
8790
match Multiaddr::from_str(addr_str) {
8891
Ok(_) => {
89-
if !config.p2p_bootstrap.contains(addr_str) {
90-
config.p2p_bootstrap.push(addr_str.clone());
91-
added_p2p += 1;
92+
if !p2p_bootstrap.contains(addr_str) {
93+
p2p_bootstrap.push(addr_str.clone());
94+
counts.p2p += 1;
9295
}
9396
}
9497
Err(e) => warn!(
@@ -101,11 +104,213 @@ pub fn merge_seeds(config: &mut Config) {
101104
}
102105
}
103106

104-
if added_http > 0 || added_p2p > 0 {
107+
counts
108+
}
109+
110+
/// Merge the embedded seed list into the runtime config.
111+
///
112+
/// - Appends any `http_url` to `config.bootstrap_peers` (used by gossip_task)
113+
/// - Appends any valid `p2p_multiaddr` to `config.p2p_bootstrap` (used by libp2p)
114+
/// - Dedupes against entries already present (env / CLI takes precedence)
115+
/// - No-op when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value
116+
pub fn merge_seeds(config: &mut Config) {
117+
if seeds_disabled() {
118+
info!("bootstrap seed list disabled via GITLAWB_BOOTSTRAP_DISABLE_SEEDS");
119+
return;
120+
}
121+
122+
let list = match parse_seed_list(EMBEDDED_PEERS_JSON) {
123+
Ok(l) => l,
124+
Err(e) => {
125+
warn!(err = %e, "failed to load embedded bootstrap-peers.json — skipping");
126+
return;
127+
}
128+
};
129+
130+
let counts = merge_into_vecs(list, &mut config.bootstrap_peers, &mut config.p2p_bootstrap);
131+
132+
if counts.http > 0 || counts.p2p > 0 {
105133
info!(
106-
http_peers = added_http,
107-
p2p_peers = added_p2p,
134+
http_peers = counts.http,
135+
p2p_peers = counts.p2p,
108136
"merged bootstrap seed list into config"
109137
);
110138
}
111139
}
140+
141+
#[cfg(test)]
142+
mod tests {
143+
use super::*;
144+
145+
#[test]
146+
fn parse_valid_v1_list() {
147+
let json = r#"{
148+
"version": 1,
149+
"updated": "2026-04-29",
150+
"peers": [
151+
{
152+
"name": "alpha",
153+
"operator": "Alice",
154+
"did": "did:key:z6MkAlice",
155+
"http_url": "https://alpha.example.com",
156+
"p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546",
157+
"added": "2026-04-29"
158+
}
159+
]
160+
}"#;
161+
let list = parse_seed_list(json).expect("should parse");
162+
assert_eq!(list.version, 1);
163+
assert_eq!(list.peers.len(), 1);
164+
assert_eq!(list.peers[0].name, "alpha");
165+
}
166+
167+
#[test]
168+
fn parse_rejects_unknown_version() {
169+
let json = r#"{ "version": 99, "peers": [] }"#;
170+
let err = parse_seed_list(json).expect_err("should reject");
171+
assert!(err.contains("unsupported"));
172+
}
173+
174+
#[test]
175+
fn parse_rejects_malformed_json() {
176+
let err = parse_seed_list("{ not json").expect_err("should reject");
177+
assert!(!err.is_empty());
178+
}
179+
180+
#[test]
181+
fn parse_accepts_empty_peers_array() {
182+
let json = r#"{ "version": 1, "peers": [] }"#;
183+
let list = parse_seed_list(json).expect("should parse");
184+
assert!(list.peers.is_empty());
185+
}
186+
187+
#[test]
188+
fn parse_treats_missing_peers_as_empty() {
189+
let json = r#"{ "version": 1 }"#;
190+
let list = parse_seed_list(json).expect("should parse");
191+
assert!(list.peers.is_empty());
192+
}
193+
194+
#[test]
195+
fn merge_appends_new_http_and_p2p() {
196+
let list = parse_seed_list(
197+
r#"{
198+
"version": 1,
199+
"peers": [
200+
{
201+
"name": "alpha",
202+
"http_url": "https://alpha.example.com",
203+
"p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546"
204+
}
205+
]
206+
}"#,
207+
)
208+
.unwrap();
209+
210+
let mut http = Vec::new();
211+
let mut p2p = Vec::new();
212+
let counts = merge_into_vecs(list, &mut http, &mut p2p);
213+
214+
assert_eq!(counts, MergeCounts { http: 1, p2p: 1 });
215+
assert_eq!(http, vec!["https://alpha.example.com"]);
216+
assert_eq!(p2p, vec!["/ip4/1.2.3.4/tcp/7546"]);
217+
}
218+
219+
#[test]
220+
fn merge_dedupes_existing_entries() {
221+
let list = parse_seed_list(
222+
r#"{
223+
"version": 1,
224+
"peers": [
225+
{ "name": "alpha", "http_url": "https://alpha.example.com" }
226+
]
227+
}"#,
228+
)
229+
.unwrap();
230+
231+
let mut http = vec!["https://alpha.example.com".to_string()];
232+
let mut p2p = Vec::new();
233+
let counts = merge_into_vecs(list, &mut http, &mut p2p);
234+
235+
assert_eq!(counts.http, 0, "should not double-add");
236+
assert_eq!(http.len(), 1);
237+
}
238+
239+
#[test]
240+
fn merge_skips_invalid_p2p_multiaddr() {
241+
let list = parse_seed_list(
242+
r#"{
243+
"version": 1,
244+
"peers": [
245+
{
246+
"name": "bad",
247+
"http_url": "https://bad.example.com",
248+
"p2p_multiaddr": "this is not a multiaddr"
249+
}
250+
]
251+
}"#,
252+
)
253+
.unwrap();
254+
255+
let mut http = Vec::new();
256+
let mut p2p = Vec::new();
257+
let counts = merge_into_vecs(list, &mut http, &mut p2p);
258+
259+
assert_eq!(counts.http, 1, "http still added");
260+
assert_eq!(counts.p2p, 0, "invalid p2p skipped");
261+
assert!(p2p.is_empty());
262+
}
263+
264+
#[test]
265+
fn merge_skips_empty_strings() {
266+
let list = parse_seed_list(
267+
r#"{
268+
"version": 1,
269+
"peers": [
270+
{ "name": "blank", "http_url": "", "p2p_multiaddr": "" }
271+
]
272+
}"#,
273+
)
274+
.unwrap();
275+
276+
let mut http = Vec::new();
277+
let mut p2p = Vec::new();
278+
let counts = merge_into_vecs(list, &mut http, &mut p2p);
279+
280+
assert_eq!(counts, MergeCounts::default());
281+
}
282+
283+
#[test]
284+
fn merge_handles_null_optional_fields() {
285+
let list = parse_seed_list(
286+
r#"{
287+
"version": 1,
288+
"peers": [
289+
{
290+
"name": "alpha",
291+
"operator": null,
292+
"did": null,
293+
"http_url": "https://alpha.example.com",
294+
"p2p_multiaddr": null,
295+
"added": null
296+
}
297+
]
298+
}"#,
299+
)
300+
.unwrap();
301+
302+
let mut http = Vec::new();
303+
let mut p2p = Vec::new();
304+
let counts = merge_into_vecs(list, &mut http, &mut p2p);
305+
306+
assert_eq!(counts, MergeCounts { http: 1, p2p: 0 });
307+
}
308+
309+
#[test]
310+
fn embedded_seed_list_parses_successfully() {
311+
// Regression: the canonical bootstrap-peers.json shipped in the repo
312+
// must always be valid, since it's compiled into the binary.
313+
parse_seed_list(EMBEDDED_PEERS_JSON)
314+
.expect("embedded bootstrap-peers.json must always parse");
315+
}
316+
}

0 commit comments

Comments
 (0)