use super::*;
use alloy::primitives::Address;
use serde_json::{Value, json};
#[path = "client/test_server.rs"]
mod http;
#[path = "service/test_rpc.rs"]
mod test_rpc;
pub(super) fn plan_args() -> Vec<String> {
["agentswap", "--url", "http://127.0.0.1:1", "--json", "batch-sell", "plan", "--chainid", "8453", "--owner", &Address::repeat_byte(4).to_string(),
"--agent", &Address::repeat_byte(2).to_string(), "--receive", "WETH", "--max-loss-bps", "500"]
.into_iter().map(String::from).collect()
}
fn child(name: &str, app: &http::Server, args: &[String]) -> std::process::Output {
spawn(name, app, args, None)
}
fn spawn(name: &str, app: &http::Server, args: &[String], refuse: Option<&str>) -> std::process::Output {
let rpc = test_rpc::TestRpc::start(|body| Some(test_rpc::ok(body, json!("0x1"))));
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
command.args(["--exact", name, "--nocapture"])
.env("BATCH_APP", &app.url).env("BATCH_ARGS", serde_json::to_string(args).unwrap())
.env("AGENTSWAP_RPC_URL", &rpc.url).env("AGENTSWAP_RPC_URL_8453", &rpc.url);
if let Some(expected) = refuse { command.env("BATCH_REFUSE", expected); }
command.output().unwrap()
}
fn enter() -> bool {
let Ok(url) = std::env::var("BATCH_APP") else { return false; };
routes::TEST_APP_ORIGIN.set(url).unwrap();
let args: Vec<String> = serde_json::from_str(&std::env::var("BATCH_ARGS").unwrap()).unwrap();
let result = tokio::runtime::Runtime::new().unwrap().block_on(run_cli(Cli::try_parse_from(args).unwrap()));
if let Ok(expected) = std::env::var("BATCH_REFUSE") {
let error = result.unwrap_err().to_string();
assert!(error.contains(&expected), "{error}");
} else { result.unwrap(); }
true
}
pub(super) fn responses(n: u8) -> Vec<(u16, String, String)> {
let tokens: Vec<_> = (1..=n).map(|i| json!({"address":Address::repeat_byte(i),"balanceRaw":"1234500","decimals":6,"symbol":"TEST"})).collect();
let mut prices = serde_json::Map::new();
for i in 1..=n { prices.insert(Address::repeat_byte(i).to_string(), json!({"priceUsd":2,"source":"defillama"})); }
prices.insert(service::token::from_registry("WETH", 8453).unwrap().address, json!({"priceUsd":4,"source":"defillama"}));
vec![(200, String::new(), json!({"chainId":8453,"owner":Address::repeat_byte(4),"indexed":true,"truncated":false,"tokens":tokens}).to_string()),
(200, String::new(), json!({"prices":prices}).to_string()),
(200, String::new(), json!({"prices":prices}).to_string()),
(201, String::new(), json!({"id":"abcdefghijklmnopqrstuv","url":"https://app.agentswap.co/grant/r/abcdefghijklmnopqrstuv","expiresAt":"2026-10-10T12:00:00Z"}).to_string())]
}
#[test]
fn batch_sell_plan_posts_one_request_at_hundred() {
if enter() { return; }
let app = http::Server::start(responses(99));
let output = child("batch_sell_tests::batch_sell_plan_posts_one_request_at_hundred", &app, &plan_args());
assert!(output.status.success(), "{} {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
let requests = app.requests.lock().unwrap();
let posts: Vec<_> = requests.iter().filter(|r| r.method == "POST").collect();
assert_eq!(posts.len(), 1);
for (post, count) in posts.iter().zip([100]) {
assert_eq!(post.target, "/api/grant-requests");
let body: Value = serde_json::from_str(&post.body).unwrap();
assert_eq!(body["tokens"].as_array().unwrap().len(), count);
assert_eq!(body["tokens"][0]["cap"], "1.2345");
assert_eq!(body["tokens"][count - 1]["cap"], "0");
assert_eq!(body["maxLossBps"], 500); assert_eq!(body["purpose"], "batch-sell");
assert_eq!(body["actions"], json!(["market","intent"]));
assert_eq!(body["v"], 1); assert_eq!(body["chainId"], 8453);
assert_eq!(body["epoch"], "1w"); assert!(body["signature"].is_null());
assert!(!post.keyed && !post.paid);
}
assert!(String::from_utf8_lossy(&output.stdout).contains("244.431"));
}
#[test]
fn batch_sell_plan_selection_is_explicit_without_small_default() {
if enter() { return; }
let app = http::Server::start(responses(3));
let mut args = plan_args();
args.extend(["--min-usd".into(), "2".into(), "--max-usd".into(), "3".into(),
"--token".into(), Address::repeat_byte(1).to_string(), "--token".into(), Address::repeat_byte(2).to_string(),
"--exclude".into(), Address::repeat_byte(2).to_string()]);
let output = child("batch_sell_tests::batch_sell_plan_selection_is_explicit_without_small_default", &app, &args);
assert!(output.status.success(), "{} {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
let requests = app.requests.lock().unwrap();
let post = requests.iter().find(|r| r.method == "POST").unwrap();
let body: Value = serde_json::from_str(&post.body).unwrap();
assert_eq!(body["tokens"].as_array().unwrap().len(), 2);
assert_eq!(body["tokens"][0]["address"], Address::repeat_byte(1).to_string());
let text = String::from_utf8_lossy(&output.stdout);
assert!(text.contains("excluded") && text.contains("not_selected"), "{text}");
}
#[test]
fn batch_sell_run_refuses_unconfirmed_before_signing_or_rpc() {
if enter() { return; }
for status in ["pending", "expired"] {
let app = http::Server::start(vec![(200, String::new(), json!({"id":"abcdefghijklmnopqrstuv", "status":status,"confirmed":null,
"request":{"v":1,"chainId":8453,"agent":Address::repeat_byte(2),"tokens":[],"maxLossBps":500,"purpose":"batch-sell"}}).to_string())]);
let args = ["agentswap", "batch-sell", "run", "--request", "abcdefghijklmnopqrstuv"].map(String::from);
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "batch_sell_tests::batch_sell_run_refuses_unconfirmed_before_signing_or_rpc", "--nocapture"])
.env("BATCH_APP", &app.url).env("BATCH_ARGS", serde_json::to_string(&args).unwrap()).env("BATCH_REFUSE", "confirmed").output().unwrap();
assert!(output.status.success(), "{} {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
let requests = app.requests.lock().unwrap();
assert_eq!(requests.len(), 1); assert_eq!(requests[0].method, "GET");
assert_eq!(requests[0].target, "/api/grant-requests/abcdefghijklmnopqrstuv");
}
}
#[test]
fn batch_sell_cli_contract_and_grant_purpose() {
assert!(Cli::try_parse_from(plan_args()).is_ok());
let mut missing = plan_args(); missing.truncate(missing.len() - 2);
assert!(Cli::try_parse_from(missing).is_err());
assert!(Cli::try_parse_from(["agentswap", "sweep", "--help"]).err().unwrap().kind() == clap::error::ErrorKind::InvalidSubcommand);
for loss in ["0", "5001"] { let mut args = plan_args(); *args.last_mut().unwrap() = loss.into(); assert!(Cli::try_parse_from(args).is_err()); }
let args = ["agentswap", "grant-link", "--chainid", "8453", "--owner", "owner", "--agent", "agent", "--token", "token", "--receive", "WETH", "--one-shot", "--purpose", "batch-sell", "--max-loss-bps", "500"];
assert!(Cli::try_parse_from(args).is_ok());
}
#[test]
fn batch_sell_run_cli_start_premium_defaults_to_100_within_0_to_1000() {
use clap::CommandFactory;
let command = Cli::command();
let run = command.find_subcommand("batch-sell").and_then(|c| c.find_subcommand("run")).unwrap();
let flag = run.get_arguments().find(|a| a.get_long() == Some("start-premium-bps")).expect("--start-premium-bps");
assert_eq!(flag.get_default_values(), ["100"]);
let args = |value: &'static str| ["agentswap", "batch-sell", "run", "--request", "abcdefghijklmnopqrstuv", "--start-premium-bps", value];
for value in ["0", "1000"] { assert!(Cli::try_parse_from(args(value)).is_ok(), "{value}"); }
for value in ["1001", "1.5"] {
assert_eq!(Cli::try_parse_from(args(value)).err().unwrap().kind(), clap::error::ErrorKind::ValueValidation, "{value}");
}
}
#[test]
fn batch_sell_plan_filters_and_economic_floor() {
if enter() { return; }
for (flag, value, reason) in [("--min-usd","3","under_min_usd"),("--max-usd","2","over_max_usd"),("gas","0","below_gas_floor"),("large","0",""),("fraction","0","over_max_usd")] {
let mut replies = responses(1);
if flag == "gas" || flag == "large" {
let index = if flag == "gas" { 2 } else { 1 };
let mut body: Value = serde_json::from_str(&replies[index].2).unwrap();
let address = if flag == "gas" { service::token::from_registry("WETH",8453).unwrap().address } else { Address::repeat_byte(1).to_string() };
body["prices"][address]["priceUsd"] = json!(1_000_000_000_000u64);
replies[index].2 = body.to_string();
}
if flag == "fraction" {
let mut body: Value = serde_json::from_str(&replies[1].2).unwrap();
body["prices"][Address::repeat_byte(1).to_string()]["priceUsd"] = serde_json::from_str("0.000000000000000001").unwrap();
replies[1].2 = body.to_string();
}
let app = http::Server::start(replies);
let mut args = plan_args();
if flag == "fraction" { args.extend(["--max-usd".into(), "0.000000000000000001".into()]); }
if flag.starts_with("--") { args.extend([flag.into(), value.into()]); }
let output = child("batch_sell_tests::batch_sell_plan_filters_and_economic_floor", &app, &args);
let text = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "{text} {}", String::from_utf8_lossy(&output.stderr));
assert!(text.contains(reason), "{flag}: {text}");
let requests = app.requests.lock().unwrap();
assert_eq!(requests.iter().filter(|r| r.method == "POST").count(), usize::from(flag == "large"));
}
}
#[test]
fn batch_sell_plan_sends_name_and_note_as_label_and_note() {
if enter() { return; }
let (name, note) = ("\u{e9}".repeat(32), "n".repeat(140));
for (extra, label, text) in [
(vec!["--name", " Dust sweeper ", "--note", "Sell small holdings for WETH"], json!("Dust sweeper"), json!("Sell small holdings for WETH")),
(vec!["--name", name.as_str(), "--note", note.as_str()], json!(name), json!(note)),
(vec![], Value::Null, Value::Null)] {
let app = http::Server::start(responses(1));
let mut args = plan_args(); args.extend(extra.into_iter().map(String::from));
let output = child("batch_sell_tests::batch_sell_plan_sends_name_and_note_as_label_and_note", &app, &args);
assert!(output.status.success(), "{} {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
let requests = app.requests.lock().unwrap();
let posts: Vec<_> = requests.iter().filter(|r| r.method == "POST").collect();
assert_eq!(posts.len(), 1);
let body: Value = serde_json::from_str(&posts[0].body).unwrap();
assert_eq!((body.get("label"), body.get("note")), (Some(&label), Some(&text)));
}
}
#[test]
fn batch_sell_plan_refuses_bad_name_or_note_before_any_request() {
if enter() { return; }
for (flag, value, error) in [("--name", "a".repeat(33), "name must be at most 32 characters"),
("--name", "\u{1f600}".repeat(16) + "a", "name must be at most 32 characters"),
("--note", "n".repeat(141), "note must be at most 140 characters"),
("--name", "Dust\u{202e}sweeper".to_string(), "name contains a control or invisible character"),
("--note", "line\nbreak".to_string(), "note contains a control or invisible character"),
("--name", " ".to_string(), "name is empty")] {
let app = http::Server::start(responses(1));
let mut args = plan_args(); args.extend([flag.to_string(), value.clone()]);
let output = spawn("batch_sell_tests::batch_sell_plan_refuses_bad_name_or_note_before_any_request", &app, &args, Some(error));
assert!(output.status.success(), "{flag} {value:?}: {} {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
assert_eq!(app.requests.lock().unwrap().len(), 0, "{flag} {value:?}");
}
}