use std::io::{Read, Write};
use std::net::TcpListener;
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
const OK_BODY: &str = r#"{"node_id":"n1","total_earned":5.0,"total_spent":0.0,"balance":5.0,"tx_count":1,"reconciles":true,"unit":"credit","tokens_per_credit":1000}"#;
const ERR_BODY: &str =
r#"{"error":{"code":"server_error","message":"An internal error occurred"}}"#;
fn serve_sequence(responses: Vec<(u16, &'static str)>) -> (u16, Arc<AtomicUsize>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
let hits = Arc::new(AtomicUsize::new(0));
let hits_bg = Arc::clone(&hits);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let idx = hits_bg.fetch_add(1, Ordering::SeqCst);
let (status, body) = responses[idx.min(responses.len() - 1)];
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let reason = if status == 200 { "OK" } else { "Error" };
let resp = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
}
});
(port, hits)
}
#[test]
fn credits_retries_once_on_transient_500() {
let (port, hits) = serve_sequence(vec![(500, ERR_BODY), (200, OK_BODY)]);
let out = Command::new(env!("CARGO_BIN_EXE_iicp-node"))
.args([
"credits",
"--node-id",
"n1",
"--token",
"t",
"--directory-url",
&format!("http://127.0.0.1:{port}"),
"--json",
])
.output()
.expect("run iicp-node");
assert!(
out.status.success(),
"transient 500 then 200 must succeed via retry; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(hits.load(Ordering::SeqCst), 2, "expected exactly one retry");
assert!(String::from_utf8_lossy(&out.stdout).contains("\"balance\""));
}
#[test]
fn credits_does_not_retry_definitive_4xx() {
let (port, hits) = serve_sequence(vec![(
401,
r#"{"error":{"code":"unauthorized","message":"invalid node_token"}}"#,
)]);
let out = Command::new(env!("CARGO_BIN_EXE_iicp-node"))
.args([
"credits",
"--node-id",
"n1",
"--token",
"bad",
"--directory-url",
&format!("http://127.0.0.1:{port}"),
])
.output()
.expect("run iicp-node");
assert!(!out.status.success(), "401 must fail");
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"definitive 4xx must not be retried"
);
}
#[test]
fn credits_all_nodes_continues_past_failing_node() {
let (bad_port, _bad_hits) = serve_sequence(vec![(500, ERR_BODY)]);
let (good_port, _good_hits) = serve_sequence(vec![(200, OK_BODY)]);
let home = std::env::temp_dir().join(format!("iicp-credits-cli-{}", std::process::id()));
let nodes_dir = home.join("nodes");
std::fs::create_dir_all(&nodes_dir).expect("mkdir");
std::fs::write(
nodes_dir.join("default.json"),
r#"{"node_id":"n-def","operator_id":"op","name":"default","backend_url":"http://b","model":"m","created_at":"2026-01-01T00:00:00Z"}"#,
)
.unwrap();
std::fs::write(
nodes_dir.join("aaa-bad.json"),
format!(
r#"{{"node_id":"n-bad","operator_id":"op","name":"aaa-bad","backend_url":"http://b","model":"m","directory_url":"http://127.0.0.1:{bad_port}","node_token":"t1","created_at":"2026-01-01T00:00:00Z"}}"#
),
)
.unwrap();
std::fs::write(
nodes_dir.join("zzz-good.json"),
format!(
r#"{{"node_id":"n-good","operator_id":"op","name":"zzz-good","backend_url":"http://b","model":"m","directory_url":"http://127.0.0.1:{good_port}","node_token":"t2","created_at":"2026-01-01T00:00:00Z"}}"#
),
)
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_iicp-node"))
.args(["credits"])
.env("IICP_HOME", &home)
.output()
.expect("run iicp-node");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("zzz-good"),
"the healthy node must still be displayed; stdout: {stdout}; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!out.status.success(),
"exit must be non-zero when any node failed"
);
let _ = std::fs::remove_dir_all(&home);
}