use std::sync::mpsc;
use tempfile::tempdir;
use crate::git_tree::AgentState;
use super::auth::{is_auth_failure, looks_auth};
use super::{LoginRun, provider_rows, start};
use crate::cli_outbound::{Chunk, Cli, ExitInfo, Streamed};
use crate::opslog::{self, SYNTHETIC_EXIT};
fn argv() -> Vec<String> {
["bz", "--login", "--provider", "openai"]
.into_iter()
.map(String::from)
.collect()
}
#[test]
fn provider_rows_scans_dump_config_names_deduped_in_order() {
let dump = "[providers.openai]\nname = \"openai\"\n\
[providers.anthropic]\nname = \"anthropic\"\n\
[providers.dup]\nname = \"openai\"\n";
assert_eq!(provider_rows(dump), vec!["openai", "anthropic"]);
assert_eq!(provider_rows(""), Vec::<String>::new());
}
#[test]
fn looks_auth_fires_on_the_credential_auth_class() {
for line in [
r#"{"type":"error","status":401}"#,
r#"{"type":"error","status":403}"#,
r#"{"message":"401 Unauthorized"}"#,
r#"{"message":"403 Forbidden"}"#,
r#"{"message":"permission denied"}"#,
r#"{"message":"permission-denied"}"#,
r#"{"message":"missing credential"}"#,
r#"{"message":"authentication failed"}"#,
r#"{"message":"not authorized"}"#,
r#"{"message":"authorisation error"}"#,
r#"{"message":"no API key configured"}"#,
r#"{"message":"invalid api_key"}"#,
r#"{"message":"bad apikey"}"#,
r#"{"error":{"code":"unauthenticated"}}"#,
] {
assert!(looks_auth(line), "should classify auth-shaped: {line}");
}
}
#[test]
fn looks_auth_ignores_non_auth_failures() {
for line in [
r#"{"type":"error","kind":"transport","message":"connection reset"}"#,
r#"{"message":"500 internal server error"}"#,
r#"{"message":"rate limit exceeded"}"#,
r#"{"author":"someone"}"#, r#"{"message":"request timeout"}"#,
] {
assert!(!looks_auth(line), "should not classify auth: {line}");
}
}
#[test]
fn is_auth_failure_needs_failed_framing_and_auth_text() {
let auth =
b"{\"type\":\"error\",\"status\":401,\"message\":\"Unauthorized\"}\n{\"type\":\"end\"}\n";
assert!(is_auth_failure(auth));
let other =
b"{\"type\":\"error\",\"kind\":\"transport\",\"message\":\"reset\"}\n{\"type\":\"end\"}\n";
assert!(!is_auth_failure(other));
let ok = b"{\"type\":\"finish\",\"reason\":\"stop\"}\n{\"type\":\"end\"}\n";
assert!(!is_auth_failure(ok));
assert!(!is_auth_failure(b""));
}
#[test]
fn latest_step_auth_failed_reads_the_last_step_only() {
let ws = tempdir().unwrap();
let steps = ws.path().join("steps").join("root-1");
std::fs::create_dir_all(steps.join("001")).unwrap();
std::fs::write(
steps.join("001").join("response.json"),
b"{\"type\":\"error\",\"kind\":\"auth\",\"message\":\"no credential for this provider: run `bz --login --provider <id>`\",\"provider_detail\":null}\n{\"type\":\"end\"}\n",
)
.unwrap();
assert!(
super::auth::latest_step_auth_failed(ws.path(), "root-1", AgentState::Stopped),
"an auth-failed latest step banners Login (§11)"
);
std::fs::create_dir_all(steps.join("002")).unwrap();
std::fs::write(
steps.join("002").join("response.json"),
b"{\"type\":\"finish\",\"reason\":\"stop\"}\n{\"type\":\"end\"}\n",
)
.unwrap();
assert!(!super::auth::latest_step_auth_failed(
ws.path(),
"root-1",
AgentState::Stopped
));
assert!(!super::auth::latest_step_auth_failed(
ws.path(),
"ghost",
AgentState::Stopped
));
}
#[test]
fn login_run_streams_lines_live_then_logs_a_clean_outcome_row() {
let dir = tempdir().unwrap();
let (tx, rx) = mpsc::channel();
let mut run = LoginRun::from_streamed(Streamed::from_rx(rx), argv(), dir.path());
assert!(run.poll());
assert!(run.view().lines.is_empty());
tx.send(Chunk::Stdout(b"code: WXYZ https://x/device\n".to_vec()))
.unwrap();
assert!(run.poll());
assert_eq!(run.view().lines, vec!["code: WXYZ https://x/device"]);
tx.send(Chunk::Exited(ExitInfo::Code(0))).unwrap();
assert!(!run.poll());
let view = run.view();
assert_eq!(view.outcome, Some(0));
assert_eq!(view.fallback, None);
assert!(!run.poll());
let ops = opslog::tail(dir.path(), 8);
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].argv, argv());
assert_eq!(ops[0].exit, 0);
assert_eq!(ops[0].stdout, "");
}
#[test]
fn login_run_nonzero_exit_carries_the_fallback_command_and_stderr() {
let dir = tempdir().unwrap();
let (tx, rx) = mpsc::channel();
let mut run = LoginRun::from_streamed(Streamed::from_rx(rx), argv(), dir.path());
tx.send(Chunk::Stderr(b"401 unauthorized\n".to_vec()))
.unwrap();
tx.send(Chunk::Exited(ExitInfo::Code(1))).unwrap();
assert!(!run.poll());
let view = run.view();
assert_eq!(view.outcome, Some(1));
assert_eq!(
view.fallback.as_deref(),
Some("bz --login --provider openai")
);
let ops = opslog::tail(dir.path(), 8);
assert_eq!(ops[0].exit, 1);
assert!(ops[0].stderr.contains("401 unauthorized"));
}
#[test]
fn start_logs_a_synthetic_row_when_bz_cannot_spawn() {
let _guard = crate::test_support::spawn_guard();
let dir = tempdir().unwrap();
let bz = Cli::new("/definitely/not/a/real/bz-xyz");
let err = start(&bz, "openai", dir.path(), "TS").err().unwrap();
assert_eq!(err.kind(), std::io::ErrorKind::Other);
let ops = opslog::tail(dir.path(), 8);
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].exit, SYNTHETIC_EXIT);
assert_eq!(
ops[0].argv,
[
"/definitely/not/a/real/bz-xyz",
"--login",
"--provider",
"openai"
]
);
assert!(!ops[0].stderr.is_empty());
}