yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Login-flow unit tests: provider-row derivation (§5.1 #20), the auth heuristic
//! table (§8.3), and the [`LoginRun`] streamed view-model + outcome row — the
//! latter driven deterministically through [`Streamed::from_rx`] so every `poll`
//! arm is covered without racing a process (the real spawn path is the S0-T5
//! story). The spawn-failure path uses a genuinely absent binary.

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() {
    // The effective `bz --dump-config` text; the `name = "..."` scan is the one
    // shared with the brazen editor (single source, no TOML dep).
    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"}"#, // 'author' must not trip a bare 'auth'
        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));
    // Failed, but a transport reset is not auth-shaped.
    let other =
        b"{\"type\":\"error\",\"kind\":\"transport\",\"message\":\"reset\"}\n{\"type\":\"end\"}\n";
    assert!(!is_auth_failure(other));
    // Complete framing is not a failure at all; nor is an empty response.
    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");
    // Step 001: the live auth-failure fixture shape (stench-pug, kind:auth).
    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)"
    );
    // Step 002 succeeds: the latest step decides, the old failure is history.
    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
    ));
    // No steps at all: nothing to banner.
    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());

    // Before any output: still running, no lines yet (the Pending arm).
    assert!(run.poll());
    assert!(run.view().lines.is_empty());

    // A device-code line arrives and paints verbatim (the Lines arm).
    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"]);

    // Clean exit settles the run (the Done arm): outcome 0, no fallback.
    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);
    // Idempotent after settle — the guard short-circuits, no second row.
    assert!(!run.poll());

    // Exactly one outcome row, its argv the login command, stdout never re-logged.
    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));
    // §8.3 fallback: the exact command to run by hand, from the same argv.
    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() {
    // Hold the binary-wide spawn lock across the fork (the `test_support`
    // discipline): even a failed exec forks, and its copied write-fd would race a
    // peer's recorder-script write into ETXTBSY without this.
    let _guard = crate::test_support::spawn_guard();
    let dir = tempdir().unwrap();
    let bz = Cli::new("/definitely/not/a/real/bz-xyz");
    // `.err()` sidesteps `unwrap_err`'s `T: Debug` bound (LoginRun holds a live
    // Stream and is deliberately not Debug).
    let err = start(&bz, "openai", dir.path(), "TS").err().unwrap();
    assert_eq!(err.kind(), std::io::ErrorKind::Other);

    // The failed spawn still leaves a rendered fact: one synthetic ops row.
    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());
}