diff --git a/src/commands/billing.rs b/src/commands/billing.rs index a47c88c..71eb52d 100644 --- a/src/commands/billing.rs +++ b/src/commands/billing.rs @@ -100,13 +100,26 @@ impl Render for PaymentsView { } }; let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["CREATED", "AMOUNT", "CURRENCY", "CARD"]); + set_header_bold( + &mut t, + ctx, + vec![ + "CREATED", + "AMOUNT", + "CURRENCY", + "STATUS", + "CARD", + "MARKETPLACE", + ], + ); for p in &data.payments { t.add_row(vec![ Cell::new(&p.created_at), Cell::new(&p.amount), Cell::new(&p.currency), + Cell::new(&p.status), opt_cell(&p.card_last_4), + opt_cell(&p.marketplace_amount), ]); } write_table(w, &t) diff --git a/src/commands/endpoint/render.rs b/src/commands/endpoint/render.rs index e001e0c..78723b4 100644 --- a/src/commands/endpoint/render.rs +++ b/src/commands/endpoint/render.rs @@ -102,7 +102,9 @@ impl Render for SingleEndpointView { ]); t.add_row(vec![Cell::new("http_url"), Cell::new(&e.http_url)]); t.add_row(vec![Cell::new("wss_url"), opt_cell(&e.wss_url)]); - if !e.tags.is_empty() { + if e.tags.is_empty() { + t.add_row(vec![Cell::new("tags"), Cell::new("—")]); + } else { let tags = e .tags .iter() @@ -111,7 +113,88 @@ impl Render for SingleEndpointView { .join(", "); t.add_row(vec![Cell::new("tags"), Cell::new(tags)]); } - write_table(w, &t) + t.add_row(vec![ + Cell::new("is_multichain"), + bool_cell(Some(e.is_multichain)), + ]); + if let Some(sec) = &e.security { + let opts = sec.options.as_ref(); + let feature = |enabled: Option, count: usize| { + let mark = match enabled { + Some(true) => "✓", + Some(false) => "✗", + None => "—", + }; + Cell::new(format!("{mark} ({count})")) + }; + t.add_row(vec![ + Cell::new("security.tokens"), + feature( + opts.and_then(|o| o.tokens), + sec.tokens.as_deref().unwrap_or(&[]).len(), + ), + ]); + t.add_row(vec![ + Cell::new("security.jwts"), + feature( + opts.and_then(|o| o.jwts), + sec.jwts.as_deref().unwrap_or(&[]).len(), + ), + ]); + t.add_row(vec![ + Cell::new("security.domain_masks"), + feature( + opts.and_then(|o| o.domain_masks), + sec.domain_masks.as_deref().unwrap_or(&[]).len(), + ), + ]); + t.add_row(vec![ + Cell::new("security.ips"), + feature( + opts.and_then(|o| o.ips), + sec.ips.as_deref().unwrap_or(&[]).len(), + ), + ]); + t.add_row(vec![ + Cell::new("security.referrers"), + feature( + opts.and_then(|o| o.referrers), + sec.referrers.as_deref().unwrap_or(&[]).len(), + ), + ]); + t.add_row(vec![ + Cell::new("security.request_filters"), + feature( + opts.and_then(|o| o.request_filters), + sec.request_filters.as_deref().unwrap_or(&[]).len(), + ), + ]); + let ip_header = opts + .and_then(|o| o.ip_custom_header.as_ref()) + .and_then(|h| h.value.clone()); + t.add_row(vec![ + Cell::new("security.ip_custom_header"), + opt_cell(&ip_header), + ]); + } + if let Some(rl) = &e.rate_limits { + t.add_row(vec![ + Cell::new("rate_limits.by_ip"), + bool_cell(rl.rate_limit_by_ip), + ]); + t.add_row(vec![ + Cell::new("rate_limits.account"), + opt_cell(&rl.account), + ]); + t.add_row(vec![Cell::new("rate_limits.rps"), opt_cell(&rl.rps)]); + t.add_row(vec![Cell::new("rate_limits.rpm"), opt_cell(&rl.rpm)]); + t.add_row(vec![Cell::new("rate_limits.rpd"), opt_cell(&rl.rpd)]); + } + write_table(w, &t)?; + if let Some(sec) = &e.security { + super::security::security_item_sections(w, ctx, sec)?; + } + Ok(()) } } @@ -160,13 +243,14 @@ impl Render for EndpointLogsView { set_header_bold( &mut t, ctx, - vec!["TIME", "METHOD", "STATUS", "NETWORK", "REQUEST_ID"], + vec!["TIME", "METHOD", "STATUS", "ERROR", "NETWORK", "REQUEST_ID"], ); for l in &self.0.data { t.add_row(vec![ Cell::new(&l.timestamp), opt_cell(&l.method), opt_cell(&l.status), + opt_cell(&l.error_code), opt_cell(&l.network), opt_cell(&l.request_id), ]); diff --git a/src/commands/endpoint/security.rs b/src/commands/endpoint/security.rs index 1a6e81f..bcbea0f 100644 --- a/src/commands/endpoint/security.rs +++ b/src/commands/endpoint/security.rs @@ -14,7 +14,7 @@ use serde::Serialize; use crate::confirm::confirm_mild; use crate::context::Ctx; use crate::errors::CliError; -use crate::output::{new_table, opt_cell, set_header_bold, write_table, Render}; +use crate::output::{bool_cell, new_table, opt_cell, set_header_bold, write_table, Render}; use crate::retry::retrying; #[derive(Debug, Subcommand)] @@ -458,75 +458,126 @@ impl Render for SecurityShowView { return Ok(()); } }; + let opts = data.options.as_ref(); let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["FEATURE", "COUNT", "DETAIL"]); - let tokens = data.tokens.as_deref().unwrap_or(&[]); + set_header_bold(&mut t, ctx, vec!["OPTION", "ENABLED"]); t.add_row(vec![ Cell::new("tokens"), - Cell::new(tokens.len()), - Cell::new(""), + bool_cell(opts.and_then(|o| o.tokens)), ]); - let referrers = data.referrers.as_deref().unwrap_or(&[]); - t.add_row(vec![ - Cell::new("referrers"), - Cell::new(referrers.len()), - Cell::new( - referrers - .iter() - .filter_map(|r| r.referrer.clone()) - .collect::>() - .join(", "), - ), - ]); - let ips = data.ips.as_deref().unwrap_or(&[]); - t.add_row(vec![ - Cell::new("ips"), - Cell::new(ips.len()), - Cell::new( - ips.iter() - .map(|i| i.ip.clone()) - .collect::>() - .join(", "), - ), - ]); - let jwts = data.jwts.as_deref().unwrap_or(&[]); t.add_row(vec![ Cell::new("jwts"), - Cell::new(jwts.len()), - Cell::new(""), + bool_cell(opts.and_then(|o| o.jwts)), ]); - let masks = data.domain_masks.as_deref().unwrap_or(&[]); t.add_row(vec![ Cell::new("domain_masks"), - Cell::new(masks.len()), - Cell::new( - masks - .iter() - .map(|d| d.domain.clone()) - .collect::>() - .join(", "), - ), + bool_cell(opts.and_then(|o| o.domain_masks)), + ]); + t.add_row(vec![Cell::new("ips"), bool_cell(opts.and_then(|o| o.ips))]); + t.add_row(vec![ + Cell::new("referrers"), + bool_cell(opts.and_then(|o| o.referrers)), ]); - let filters = data.request_filters.as_deref().unwrap_or(&[]); t.add_row(vec![ Cell::new("request_filters"), - Cell::new(filters.len()), - Cell::new(""), + bool_cell(opts.and_then(|o| o.request_filters)), ]); - let ip_header = data - .options - .as_ref() + let ip_header = opts .and_then(|o| o.ip_custom_header.as_ref()) .and_then(|h| h.value.clone()); - t.add_row(vec![ - Cell::new("ip_custom_header"), - opt_cell(&ip_header), - Cell::new(""), - ]); - write_table(w, &t) + t.add_row(vec![Cell::new("ip_custom_header"), opt_cell(&ip_header)]); + write_table(w, &t)?; + security_item_sections(w, ctx, data) } } +/// Renders one titled table per configured security item list (TOKENS, JWTS, +/// ...). Lists with no items render no section so lightly-configured +/// endpoints stay compact. Shared between `endpoint security show` and +/// `endpoint show`. +pub(crate) fn security_item_sections( + w: &mut dyn std::io::Write, + ctx: &crate::output::OutputCtx, + data: &quicknode_sdk::admin::EndpointSecurity, +) -> std::io::Result<()> { + let section = |w: &mut dyn std::io::Write, + title: &str, + headers: Vec<&str>, + rows: Vec>| + -> std::io::Result<()> { + if rows.is_empty() { + return Ok(()); + } + writeln!(w)?; + writeln!(w, "{} ({})", title, rows.len())?; + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, headers); + for row in rows { + t.add_row(row); + } + write_table(w, &t) + }; + + let tokens = data.tokens.as_deref().unwrap_or(&[]); + section( + w, + "TOKENS", + vec!["ID", "TOKEN"], + tokens + .iter() + .map(|t| vec![Cell::new(&t.id), Cell::new(&t.token)]) + .collect(), + )?; + let jwts = data.jwts.as_deref().unwrap_or(&[]); + section( + w, + "JWTS", + vec!["ID", "NAME", "KID"], + jwts.iter() + .map(|j| vec![Cell::new(&j.id), Cell::new(&j.name), Cell::new(&j.kid)]) + .collect(), + )?; + let referrers = data.referrers.as_deref().unwrap_or(&[]); + section( + w, + "REFERRERS", + vec!["ID", "REFERRER"], + referrers + .iter() + .map(|r| vec![Cell::new(&r.id), opt_cell(&r.referrer)]) + .collect(), + )?; + let masks = data.domain_masks.as_deref().unwrap_or(&[]); + section( + w, + "DOMAIN_MASKS", + vec!["ID", "DOMAIN"], + masks + .iter() + .map(|d| vec![Cell::new(&d.id), Cell::new(&d.domain)]) + .collect(), + )?; + let ips = data.ips.as_deref().unwrap_or(&[]); + section( + w, + "IPS", + vec!["ID", "IP"], + ips.iter() + .map(|i| vec![Cell::new(&i.id), Cell::new(&i.ip)]) + .collect(), + )?; + let filters = data.request_filters.as_deref().unwrap_or(&[]); + section( + w, + "REQUEST_FILTERS", + vec!["ID", "METHODS"], + filters + .iter() + .map(|f| vec![Cell::new(&f.id), Cell::new(f.method.join(", "))]) + .collect(), + ) +} + #[derive(Serialize)] struct SecurityOptionsView(quicknode_sdk::admin::GetSecurityOptionsResponse); diff --git a/src/commands/team.rs b/src/commands/team.rs index b9adc99..5196268 100644 --- a/src/commands/team.rs +++ b/src/commands/team.rs @@ -266,7 +266,36 @@ impl Render for TeamDetailView { Cell::new("default_role"), opt_cell(&detail.default_role), ]); - write_table(w, &t) + t.add_row(vec![ + Cell::new("members_count"), + opt_cell(&detail.members_count), + ]); + write_table(w, &t)?; + + let member_section = |w: &mut dyn std::io::Write, + title: &str, + users: &[quicknode_sdk::admin::TeamUser]| + -> std::io::Result<()> { + if users.is_empty() { + return Ok(()); + } + writeln!(w)?; + writeln!(w, "{} ({})", title, users.len())?; + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["ID", "EMAIL", "NAME", "ROLE", "STATUS"]); + for u in users { + t.add_row(vec![ + Cell::new(u.id), + Cell::new(&u.email), + opt_cell(&u.full_name), + opt_cell(&u.role), + opt_cell(&u.status), + ]); + } + write_table(w, &t) + }; + member_section(w, "MEMBERS", &detail.users)?; + member_section(w, "PENDING_INVITES", &detail.pending_invites) } } diff --git a/src/commands/usage.rs b/src/commands/usage.rs index c46a482..3a6a020 100644 --- a/src/commands/usage.rs +++ b/src/commands/usage.rs @@ -7,7 +7,7 @@ use serde::Serialize; use crate::context::Ctx; use crate::errors::CliError; -use crate::output::{new_table, opt_cell, set_header_bold, write_table, Render}; +use crate::output::{bool_cell, new_table, opt_cell, set_header_bold, write_table, Render}; use crate::retry::retrying; use crate::time_arg; @@ -128,6 +128,16 @@ impl Render for UsageSummaryView { opt_cell(&data.credits_remaining), ]); t.add_row(vec![Cell::new("limit"), opt_cell(&data.limit)]); + t.add_row(vec![Cell::new("overages"), opt_cell(&data.overages)]); + // The API reports the window as unix seconds; show RFC-3339 like the + // --from/--to flags accept. Out-of-range values fall back to the raw + // number rather than erroring a read-only command. + let time_cell = |ts: i64| match time_arg::ParsedTime::from_unix(ts) { + Some(t) => Cell::new(t.to_rfc3339()), + None => Cell::new(ts), + }; + t.add_row(vec![Cell::new("start_time"), time_cell(data.start_time)]); + t.add_row(vec![Cell::new("end_time"), time_cell(data.end_time)]); write_table(w, &t) } } @@ -179,12 +189,18 @@ impl Render for UsageByMethodView { } }; let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["METHOD", "CREDITS", "ARCHIVE"]); + set_header_bold( + &mut t, + ctx, + vec!["METHOD", "CHAIN", "NETWORK", "CREDITS", "ARCHIVE"], + ); for m in &data.methods { t.add_row(vec![ Cell::new(&m.method_name), + opt_cell(&m.chain), + opt_cell(&m.network), Cell::new(m.credits_used), - opt_cell(&m.archive), + bool_cell(m.archive), ]); } write_table(w, &t) @@ -233,12 +249,13 @@ impl Render for UsageByTagView { } }; let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["TAG_ID", "LABEL", "CREDITS"]); + set_header_bold(&mut t, ctx, vec!["TAG_ID", "LABEL", "CREDITS", "REQUESTS"]); for tg in &data.tags { t.add_row(vec![ opt_cell(&tg.tag_id), Cell::new(&tg.label), Cell::new(tg.credits_used), + Cell::new(tg.requests), ]); } write_table(w, &t) diff --git a/src/time_arg.rs b/src/time_arg.rs index 33c9558..e8f8985 100644 --- a/src/time_arg.rs +++ b/src/time_arg.rs @@ -28,6 +28,11 @@ impl ParsedTime { self.0.unix_timestamp() } + /// Builds from unix seconds; `None` if out of `OffsetDateTime` range. + pub fn from_unix(ts: i64) -> Option { + OffsetDateTime::from_unix_timestamp(ts).ok().map(Self) + } + pub fn to_rfc3339(self) -> String { self.0 .format(&Rfc3339) diff --git a/tests/snapshots/table_snapshots__billing_payments_table_includes_status_and_marketplace.snap b/tests/snapshots/table_snapshots__billing_payments_table_includes_status_and_marketplace.snap new file mode 100644 index 0000000..cea574b --- /dev/null +++ b/tests/snapshots/table_snapshots__billing_payments_table_includes_status_and_marketplace.snap @@ -0,0 +1,7 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +CREATED AMOUNT CURRENCY STATUS CARD MARKETPLACE +2026-01-01T00:00:00Z 49.00 usd succeeded 4242 9.00 +2026-02-01T00:00:00Z 49.00 usd failed — — diff --git a/tests/snapshots/table_snapshots__endpoint_logs_table_includes_error_code.snap b/tests/snapshots/table_snapshots__endpoint_logs_table_includes_error_code.snap new file mode 100644 index 0000000..062f797 --- /dev/null +++ b/tests/snapshots/table_snapshots__endpoint_logs_table_includes_error_code.snap @@ -0,0 +1,7 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +TIME METHOD STATUS ERROR NETWORK REQUEST_ID +2026-01-01T00:00:00.000Z eth_blockNumbre 200 -32601 mainnet req-1 +2026-01-01T00:00:01.000Z eth_blockNumber 200 — mainnet req-2 diff --git a/tests/snapshots/table_snapshots__endpoint_security_show_all_sections.snap b/tests/snapshots/table_snapshots__endpoint_security_show_all_sections.snap new file mode 100644 index 0000000..96fb526 --- /dev/null +++ b/tests/snapshots/table_snapshots__endpoint_security_show_all_sections.snap @@ -0,0 +1,37 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +OPTION ENABLED +tokens ✓ +jwts ✓ +domain_masks ✓ +ips ✓ +referrers ✓ +request_filters ✓ +ip_custom_header x-real-ip + +TOKENS (2) +ID TOKEN +tok-1 0xabc +tok-2 0xdef + +JWTS (1) +ID NAME KID +jwt-1 ci kid-1 + +REFERRERS (1) +ID REFERRER +ref-1 https://app.example.com + +DOMAIN_MASKS (1) +ID DOMAIN +dm-1 *.example.com + +IPS (1) +ID IP +ip-1 203.0.113.7 + +REQUEST_FILTERS (1) +ID METHODS +rf-1 eth_blockNumber, eth_call diff --git a/tests/snapshots/table_snapshots__endpoint_security_show_single_token_omits_empty_sections.snap b/tests/snapshots/table_snapshots__endpoint_security_show_single_token_omits_empty_sections.snap new file mode 100644 index 0000000..fd0fbc0 --- /dev/null +++ b/tests/snapshots/table_snapshots__endpoint_security_show_single_token_omits_empty_sections.snap @@ -0,0 +1,16 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +OPTION ENABLED +tokens ✓ +jwts ✗ +domain_masks ✗ +ips ✗ +referrers ✗ +request_filters ✗ +ip_custom_header — + +TOKENS (1) +ID TOKEN +tok-1 0xabc diff --git a/tests/snapshots/table_snapshots__endpoint_show_full_table.snap b/tests/snapshots/table_snapshots__endpoint_show_full_table.snap new file mode 100644 index 0000000..1a0bd2b --- /dev/null +++ b/tests/snapshots/table_snapshots__endpoint_show_full_table.snap @@ -0,0 +1,33 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +FIELD VALUE +id ep-1 +label — +status active +chain/network eth/mainnet +http_url https://ep-1.example +wss_url wss://ep-1.example +tags — +is_multichain ✗ +security.tokens ✓ (1) +security.jwts ✗ (0) +security.domain_masks ✗ (0) +security.ips ✗ (0) +security.referrers ✗ (0) +security.request_filters ✓ (1) +security.ip_custom_header x-real-ip +rate_limits.by_ip ✗ +rate_limits.account -1 +rate_limits.rps -1 +rate_limits.rpm -1 +rate_limits.rpd -1 + +TOKENS (1) +ID TOKEN +tok-1 0xabc + +REQUEST_FILTERS (1) +ID METHODS +rf-1 eth_blockNumber, eth_call diff --git a/tests/snapshots/table_snapshots__endpoint_show_minimal_table_omits_security_and_rate_limit_rows.snap b/tests/snapshots/table_snapshots__endpoint_show_minimal_table_omits_security_and_rate_limit_rows.snap new file mode 100644 index 0000000..bb12bed --- /dev/null +++ b/tests/snapshots/table_snapshots__endpoint_show_minimal_table_omits_security_and_rate_limit_rows.snap @@ -0,0 +1,13 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +FIELD VALUE +id ep-1 +label — +status — +chain/network solana/mainnet +http_url https://ep-1.example +wss_url — +tags — +is_multichain ✗ diff --git a/tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap b/tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap new file mode 100644 index 0000000..e0571c0 --- /dev/null +++ b/tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap @@ -0,0 +1,18 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +FIELD VALUE +id 7 +name core +default_role viewer +members_count 2 + +MEMBERS (2) +ID EMAIL NAME ROLE STATUS +1 alice@example.com Alice Example admin active +2 bob@example.com — viewer active + +PENDING_INVITES (1) +ID EMAIL NAME ROLE STATUS +3 carol@example.com — viewer pending diff --git a/tests/snapshots/table_snapshots__team_show_without_members_renders_fields_only.snap b/tests/snapshots/table_snapshots__team_show_without_members_renders_fields_only.snap new file mode 100644 index 0000000..c4c9560 --- /dev/null +++ b/tests/snapshots/table_snapshots__team_show_without_members_renders_fields_only.snap @@ -0,0 +1,9 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +FIELD VALUE +id 7 +name core +default_role — +members_count — diff --git a/tests/snapshots/table_snapshots__usage_by_method_table_includes_chain_and_network.snap b/tests/snapshots/table_snapshots__usage_by_method_table_includes_chain_and_network.snap new file mode 100644 index 0000000..d674cfa --- /dev/null +++ b/tests/snapshots/table_snapshots__usage_by_method_table_includes_chain_and_network.snap @@ -0,0 +1,7 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +METHOD CHAIN NETWORK CREDITS ARCHIVE +eth_call eth mainnet 900 ✗ +getBlockHeight solana mainnet 300 — diff --git a/tests/snapshots/table_snapshots__usage_by_tag_table_includes_requests.snap b/tests/snapshots/table_snapshots__usage_by_tag_table_includes_requests.snap new file mode 100644 index 0000000..bb30952 --- /dev/null +++ b/tests/snapshots/table_snapshots__usage_by_tag_table_includes_requests.snap @@ -0,0 +1,7 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +TAG_ID LABEL CREDITS REQUESTS +1 prod 1000 420 +— untagged 200 80 diff --git a/tests/snapshots/table_snapshots__usage_summary_table_shows_overages_and_window.snap b/tests/snapshots/table_snapshots__usage_summary_table_shows_overages_and_window.snap new file mode 100644 index 0000000..7868fd7 --- /dev/null +++ b/tests/snapshots/table_snapshots__usage_summary_table_shows_overages_and_window.snap @@ -0,0 +1,11 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +FIELD VALUE +credits_used 1200 +credits_remaining 8800 +limit 10000 +overages 0 +start_time 2026-01-01T00:00:00Z +end_time 2026-02-01T00:00:00Z diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs new file mode 100644 index 0000000..97d8bcd --- /dev/null +++ b/tests/table_snapshots.rs @@ -0,0 +1,365 @@ +//! Snapshot tests for human-readable table output, rendered by the real +//! binary against a wiremock server. +//! +//! Unlike `output_snapshots.rs` (which pins layout via re-declared renderers), +//! these run `qn` as a subprocess so the snapshot covers the actual +//! decode-and-render path for each command. + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Mount `body` at `GET url_path`, run `qn --format table ` against the +/// mock server, and return stdout. Panics (with stderr) on non-zero exit. +async fn table_stdout(url_path: &str, body: serde_json::Value, args: &[&str]) -> String { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(url_path)) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&server) + .await; + + let uri = server.uri(); + let mut argv = vec![ + "--api-key", + "test", + "--base-url", + uri.as_str(), + "--no-input", + "--format", + "table", + ]; + argv.extend(args); + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .env_remove("HOME") + .env("HOME", std::env::temp_dir()) + .args(&argv) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[tokio::test] +async fn endpoint_show_full_table() { + let body = serde_json::json!({ + "data": { + "id": "ep-1", + "label": null, + "status": "active", + "chain": "eth", + "network": "mainnet", + "http_url": "https://ep-1.example", + "wss_url": "wss://ep-1.example", + "security": { + "options": { + "tokens": true, + "jwts": false, + "domainMasks": false, + "ips": false, + "referrers": false, + "requestFilters": true, + "ipCustomHeader": { "value": "x-real-ip" } + }, + "tokens": [ + { "id": "tok-1", "token": "0xabc" } + ], + "jwts": null, + "referrers": null, + "domain_masks": null, + "ips": null, + "request_filters": [ + { "id": "rf-1", "method": ["eth_blockNumber", "eth_call"] } + ] + }, + "rate_limits": { + "rate_limit_by_ip": false, + "account": -1, + "rps": -1, + "rpm": -1, + "rpd": -1 + }, + "tags": [], + "is_multichain": false + } + }); + let out = table_stdout("/v0/endpoints/ep-1", body, &["endpoint", "show", "ep-1"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn endpoint_security_show_all_sections() { + let body = serde_json::json!({ + "data": { + "options": { + "tokens": true, + "jwts": true, + "domainMasks": true, + "ips": true, + "referrers": true, + "requestFilters": true, + "ipCustomHeader": { "value": "x-real-ip" } + }, + "tokens": [ + { "id": "tok-1", "token": "0xabc" }, + { "id": "tok-2", "token": "0xdef" } + ], + "jwts": [ + { "id": "jwt-1", "public_key": "pk", "kid": "kid-1", "name": "ci" } + ], + "referrers": [ + { "id": "ref-1", "referrer": "https://app.example.com" } + ], + "domain_masks": [ + { "id": "dm-1", "domain": "*.example.com" } + ], + "ips": [ + { "id": "ip-1", "ip": "203.0.113.7" } + ], + "request_filters": [ + { "id": "rf-1", "method": ["eth_blockNumber", "eth_call"] } + ] + }, + "error": null + }); + let out = table_stdout( + "/v0/endpoints/ep-1/security", + body, + &["endpoint", "security", "show", "ep-1"], + ) + .await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn endpoint_security_show_single_token_omits_empty_sections() { + let body = serde_json::json!({ + "data": { + "options": { + "tokens": true, + "jwts": false, + "domainMasks": false, + "ips": false, + "referrers": false, + "requestFilters": false, + "ipCustomHeader": { "value": null } + }, + "tokens": [ + { "id": "tok-1", "token": "0xabc" } + ], + "jwts": null, + "referrers": null, + "domain_masks": null, + "ips": null, + "request_filters": null + }, + "error": null + }); + let out = table_stdout( + "/v0/endpoints/ep-1/security", + body, + &["endpoint", "security", "show", "ep-1"], + ) + .await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn endpoint_logs_table_includes_error_code() { + let body = serde_json::json!({ + "data": [ + { + "timestamp": "2026-01-01T00:00:00.000Z", + "method": "eth_blockNumbre", + "network": "mainnet", + "http_method": "POST", + "status": 200, + "error_code": -32601, + "url": "/", + "request_id": "req-1", + "details": null + }, + { + "timestamp": "2026-01-01T00:00:01.000Z", + "method": "eth_blockNumber", + "network": "mainnet", + "http_method": "POST", + "status": 200, + "error_code": null, + "url": "/", + "request_id": "req-2", + "details": null + } + ], + "next_at": null + }); + let out = table_stdout( + "/v0/endpoints/ep-1/logs", + body, + &["endpoint", "logs", "ep-1", "--from", "2026-01-01T00:00:00Z"], + ) + .await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn team_show_lists_members_and_pending_invites() { + let body = serde_json::json!({ + "data": { + "id": 7, + "name": "core", + "default_role": "viewer", + "members_count": 2, + "users": [ + { + "id": 1, + "full_name": "Alice Example", + "email": "alice@example.com", + "role": "admin", + "status": "active", + "created_at": "2026-01-01T00:00:00Z", + "photo_url": null + }, + { + "id": 2, + "full_name": null, + "email": "bob@example.com", + "role": "viewer", + "status": "active", + "created_at": null, + "photo_url": null + } + ], + "pending_invites": [ + { + "id": 3, + "full_name": null, + "email": "carol@example.com", + "role": "viewer", + "status": "pending", + "created_at": null, + "photo_url": null + } + ] + }, + "error": null + }); + let out = table_stdout("/v0/teams/7", body, &["team", "show", "7"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn team_show_without_members_renders_fields_only() { + let body = serde_json::json!({ + "data": { "id": 7, "name": "core", "default_role": null } + }); + let out = table_stdout("/v0/teams/7", body, &["team", "show", "7"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn billing_payments_table_includes_status_and_marketplace() { + let body = serde_json::json!({ + "data": { + "payments": [ + { + "amount": "49.00", + "card_last_4": "4242", + "created_at": "2026-01-01T00:00:00Z", + "currency": "usd", + "status": "succeeded", + "marketplace_amount": "9.00" + }, + { + "amount": "49.00", + "card_last_4": null, + "created_at": "2026-02-01T00:00:00Z", + "currency": "usd", + "status": "failed", + "marketplace_amount": null + } + ] + }, + "error": null + }); + let out = table_stdout("/v0/billing/payments", body, &["billing", "payments"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn usage_summary_table_shows_overages_and_window() { + let body = serde_json::json!({ + "data": { + "credits_used": 1200, + "credits_remaining": 8800, + "limit": 10000, + "overages": 0, + "start_time": 1767225600, + "end_time": 1769904000 + }, + "error": null + }); + let out = table_stdout("/v0/usage/rpc", body, &["usage", "summary"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn usage_by_method_table_includes_chain_and_network() { + let body = serde_json::json!({ + "data": { + "methods": [ + { + "method_name": "eth_call", + "credits_used": 900, + "archive": false, + "network": "mainnet", + "chain": "eth" + }, + { + "method_name": "getBlockHeight", + "credits_used": 300, + "archive": null, + "network": "mainnet", + "chain": "solana" + } + ] + }, + "error": null + }); + let out = table_stdout("/v0/usage/rpc/by-method", body, &["usage", "by-method"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn usage_by_tag_table_includes_requests() { + let body = serde_json::json!({ + "data": { + "tags": [ + { "tag_id": 1, "label": "prod", "credits_used": 1000, "requests": 420 }, + { "tag_id": null, "label": "untagged", "credits_used": 200, "requests": 80 } + ] + }, + "error": null + }); + let out = table_stdout("/v0/usage/rpc/by-tag", body, &["usage", "by-tag"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn endpoint_show_minimal_table_omits_security_and_rate_limit_rows() { + let body = serde_json::json!({ + "data": { + "id": "ep-1", + "chain": "solana", + "network": "mainnet", + "http_url": "https://ep-1.example", + "tags": [] + } + }); + let out = table_stdout("/v0/endpoints/ep-1", body, &["endpoint", "show", "ep-1"]).await; + insta::assert_snapshot!(out); +}