Ctrl K
ComunidadGuías…

crates/focuser-cli/tests/cli.rs

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! End-to-end tests that run the real `focuser` binary against a scratch database.
//!
//! These are the tests that replace manual release checking. They exercise the
//! same `execute()` the GUI calls, so a scenario passing here is evidence about
//! the shipped application, not about a mock.
//!
//! `CARGO_BIN_EXE_focuser-cli` is set by Cargo for integration tests, so the
//! binary under test is always the one just built.

use std::path::Path;
use std::process::{Command, Output};

use tempfile::TempDir;

/// A CLI invocation against an isolated database.
struct Cli {
    _dir: TempDir,
    db: std::path::PathBuf,
}

impl Cli {
    fn new() -> Self {
        let dir = TempDir::new().expect("temp dir");
        let db = dir.path().join("test.db");
        Self { _dir: dir, db }
    }

    fn raw(&self, args: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_focuser-cli"))
            .arg("--db")
            .arg(&self.db)
            .args(args)
            .output()
            .expect("failed to run focuser-cli")
    }

    /// Run, expecting success. Returns trimmed stdout.
    fn ok(&self, args: &[&str]) -> String {
        let out = self.raw(args);
        assert!(
            out.status.success(),
            "expected `{}` to succeed, got exit {:?}\nstderr: {}",
            args.join(" "),
            out.status.code(),
            String::from_utf8_lossy(&out.stderr),
        );
        String::from_utf8_lossy(&out.stdout).trim().to_string()
    }

    /// Run, expecting failure. Returns (exit code, trimmed stderr).
    fn fail(&self, args: &[&str]) -> (i32, String) {
        let out = self.raw(args);
        assert!(
            !out.status.success(),
            "expected `{}` to fail, but it succeeded",
            args.join(" ")
        );
        (
            out.status.code().unwrap_or(-1),
            String::from_utf8_lossy(&out.stderr).trim().to_string(),
        )
    }

    /// Run with `--json` and parse the result.
    fn json(&self, args: &[&str]) -> serde_json::Value {
        let mut with_json = vec!["--json"];
        with_json.extend_from_slice(args);
        serde_json::from_str(&self.ok(&with_json)).expect("stdout was not valid JSON")
    }

    /// Create a list and return its id.
    fn make_list(&self, name: &str) -> String {
        self.json(&["list", "create", name])["data"]["id"]
            .as_str()
            .expect("created list should carry an id")
            .to_string()
    }
}

#[test]
fn creating_a_list_makes_it_appear_in_ls() {
    let cli = Cli::new();
    let id = cli.make_list("Social");

    let lists = cli.json(&["list", "ls"]);
    let data = lists["data"].as_array().unwrap();

    assert_eq!(data.len(), 1);
    assert_eq!(data[0]["id"], id);
    assert_eq!(data[0]["name"], "Social");
    assert_eq!(data[0]["enabled"], true);
}

#[test]
fn json_output_is_machine_readable_and_human_output_is_not_required_to_be() {
    let cli = Cli::new();
    cli.make_list("Social");

    // The contract scripts rely on: `--json` parses, and carries a discriminant.
    let result = cli.json(&["list", "ls"]);
    assert_eq!(result["kind"], "block_lists");

    // Human output must at least mention the name; its exact shape is not a
    // stable interface and is deliberately not asserted further.
    assert!(cli.ok(&["list", "ls"]).contains("Social"));
}

#[test]
fn exit_codes_distinguish_failure_kinds() {
    let cli = Cli::new();
    let missing = "00000000-0000-0000-0000-000000000000";

    // Validation — the caller sent something invalid.
    let (code, stderr) = cli.fail(&["list", "create", "   "]);
    assert_eq!(code, 2, "validation should exit 2");
    assert!(stderr.starts_with("validation:"), "got: {stderr}");

    // Not found — the caller referenced something that does not exist.
    let (code, stderr) = cli.fail(&["list", "rm", missing]);
    assert_eq!(code, 4, "not-found should exit 4");
    assert!(stderr.starts_with("block_list_not_found:"), "got: {stderr}");
}

#[test]
fn a_missing_rule_is_reported_rather_than_silently_succeeding() {
    let cli = Cli::new();
    let list = cli.make_list("Sites");
    let missing = "00000000-0000-0000-0000-000000000000";

    // The pre-rewrite command used Vec::retain and returned success here.
    let (code, stderr) = cli.fail(&["site", "rm", &list, missing]);
    assert_eq!(code, 4);
    assert!(stderr.contains("rule_not_found"), "got: {stderr}");
}

#[test]
fn full_website_rule_lifecycle() {
    let cli = Cli::new();
    let list = cli.make_list("Sites");

    let rule = cli.json(&["site", "add", &list, "example.com"])["data"]["id"]
        .as_str()
        .unwrap()
        .to_string();

    let after_add = cli.json(&["list", "ls"]);
    assert_eq!(
        after_add["data"][0]["websites"].as_array().unwrap().len(),
        1
    );

    cli.ok(&["site", "rm", &list, &rule]);

    let after_rm = cli.json(&["list", "ls"]);
    assert!(
        after_rm["data"][0]["websites"]
            .as_array()
            .unwrap()
            .is_empty()
    );
}

#[test]
fn bulk_import_reports_how_many_values_it_accepted() {
    let cli = Cli::new();
    let list = cli.make_list("Sites");

    let added = cli.json(&[
        "site",
        "import",
        &list,
        "example.com",
        "EXAMPLE.com", // duplicate once normalised
        "# comment",
        "",
        "other.com",
    ]);

    assert_eq!(added["kind"], "count");
    assert_eq!(added["data"], 2);
}

#[test]
fn bulk_import_reads_a_file() {
    let cli = Cli::new();
    let list = cli.make_list("Sites");

    let path = cli.db.parent().unwrap().join("domains.txt");
    std::fs::write(&path, "one.com\n# a comment\n\ntwo.com\n").unwrap();

    let added = cli.json(&["site", "import", &list, "--file", path.to_str().unwrap()]);
    assert_eq!(added["data"], 2);
}

#[test]
fn toggling_a_list_persists_across_invocations() {
    let cli = Cli::new();
    let id = cli.make_list("Social");

    cli.ok(&["list", "disable", &id]);
    assert_eq!(cli.json(&["list", "ls"])["data"][0]["enabled"], false);

    cli.ok(&["list", "enable", &id]);
    assert_eq!(cli.json(&["list", "ls"])["data"][0]["enabled"], true);
}

#[test]
fn protection_prevents_modification_and_is_reported_by_status() {
    let cli = Cli::new();
    let id = cli.make_list("Committed");

    cli.ok(&["protect", "enable", &id, "--minutes", "60"]);

    let (code, stderr) = cli.fail(&["list", "disable", &id]);
    assert_eq!(code, 5, "protected should exit 5");
    assert!(stderr.starts_with("protected:"), "got: {stderr}");

    // Deleting a protected list must be refused too — this is the path the old
    // CLI bypassed entirely by using SetBlockListEnabled.
    assert_eq!(cli.fail(&["list", "rm", &id]).0, 5);

    let status = cli.json(&["protect", "status"]);
    assert_eq!(status["data"].as_array().unwrap().len(), 1);
    assert_eq!(status["data"][0]["block_list_id"], id);
    assert_eq!(
        status["data"][0]["prevent_modification"], true,
        "protection must default to actually protecting"
    );
}

#[test]
fn protection_opt_outs_are_honoured() {
    let cli = Cli::new();
    let id = cli.make_list("Partly locked");

    cli.ok(&[
        "protect",
        "enable",
        &id,
        "--minutes",
        "60",
        "--allow-modification",
    ]);

    let info = &cli.json(&["protect", "status"])["data"][0];
    assert_eq!(info["prevent_modification"], false);
    assert_eq!(info["prevent_uninstall"], true, "others stay on");

    // With modification permitted, editing the list is allowed again.
    cli.ok(&["site", "add", &id, "example.com"]);
}

#[test]
fn schedule_slots_parse_and_round_trip() {
    let cli = Cli::new();
    let id = cli.make_list("Work");

    cli.ok(&[
        "schedule",
        "set",
        &id,
        "--slot",
        "mon:09:00-17:00",
        "--slot",
        "sat:9-12",
    ]);

    let schedule = &cli.json(&["list", "ls"])["data"][0]["schedule"];
    let slots = schedule["time_slots"].as_array().unwrap();
    assert_eq!(slots.len(), 2);
    assert_eq!(slots[0]["day"], "Mon");

    cli.ok(&["schedule", "set", &id, "--always"]);
    assert!(cli.json(&["list", "ls"])["data"][0]["schedule"].is_null());
}

#[test]
fn an_unparseable_slot_is_rejected_before_touching_the_database() {
    let cli = Cli::new();
    let id = cli.make_list("Work");

    let (code, stderr) = cli.fail(&["schedule", "set", &id, "--slot", "funday:9-17"]);
    // clap rejects the value, so this never reaches execute().
    assert_eq!(code, 2, "clap usage errors exit 2");
    assert!(stderr.contains("funday"), "error should name the bad value");
}

#[test]
fn settings_distinguish_unset_from_empty() {
    let cli = Cli::new();

    let unset = cli.json(&["setting", "get", "theme"]);
    assert!(unset["data"].is_null(), "unset should be null, not \"\"");

    cli.ok(&["setting", "set", "theme", ""]);
    let empty = cli.json(&["setting", "get", "theme"]);
    assert_eq!(empty["data"], "", "an explicitly empty value is not null");
}

#[test]
fn allowance_lifecycle_and_validation() {
    let cli = Cli::new();

    let (code, _) = cli.fail(&[
        "allowance",
        "create",
        "youtube.com",
        "--limit-secs",
        "30", // below the 60s minimum
    ]);
    assert_eq!(code, 2);

    let created = cli.json(&[
        "allowance",
        "create",
        "youtube.com",
        "--limit-secs",
        "600",
        "--strict",
    ]);
    let id = created["data"]["id"].as_str().unwrap().to_string();

    let listed = cli.json(&["allowance", "ls"]);
    assert_eq!(listed["data"].as_array().unwrap().len(), 1);
    assert_eq!(listed["data"][0]["remaining_secs"], 600);

    cli.ok(&["allowance", "rm", &id]);
    assert!(
        cli.json(&["allowance", "ls"])["data"]
            .as_array()
            .unwrap()
            .is_empty()
    );
}

#[test]
fn pomodoro_reports_idle_then_running() {
    let cli = Cli::new();
    let list = cli.make_list("Focus");

    assert!(cli.json(&["pomodoro", "status"])["data"].is_null());

    cli.ok(&["pomodoro", "start", &list]);

    let status = cli.json(&["pomodoro", "status"]);
    assert_eq!(status["data"]["block_list_name"], "Focus");
    assert_eq!(status["data"]["paused"], false);

    assert_eq!(cli.json(&["pomodoro", "pause"])["data"], true);
    assert_eq!(cli.json(&["pomodoro", "stop"])["data"], true);

    // Stopping again is a no-op and must report that rather than claiming success.
    assert_eq!(cli.json(&["pomodoro", "stop"])["data"], false);
}

#[test]
fn stats_reject_a_backwards_date_range() {
    let cli = Cli::new();

    let (code, stderr) = cli.fail(&[
        "stats",
        "show",
        "--from",
        "2026-05-10",
        "--to",
        "2026-05-01",
    ]);
    assert_eq!(code, 2);
    assert!(stderr.contains("after"), "got: {stderr}");
}

#[test]
fn stats_retention_round_trips() {
    let cli = Cli::new();

    assert_eq!(cli.json(&["stats", "retention"])["data"], 30);
    cli.ok(&["stats", "set-retention", "7"]);
    assert_eq!(cli.json(&["stats", "retention"])["data"], 7);

    assert_eq!(cli.fail(&["stats", "set-retention", "0"]).0, 2);
}

#[test]
fn clear_all_skips_protected_lists() {
    let cli = Cli::new();

    let open = cli.make_list("Open");
    let locked = cli.make_list("Locked");
    for id in [&open, &locked] {
        cli.ok(&["site", "add", id, "x.com"]);
    }
    cli.ok(&["protect", "enable", &locked, "--minutes", "60"]);

    // One locked list must not fail the whole bulk operation.
    assert_eq!(cli.json(&["site", "clear"])["data"], 1);
}

#[test]
fn the_database_flag_isolates_runs_from_each_other() {
    let a = Cli::new();
    let b = Cli::new();

    a.make_list("Only in A");

    assert_eq!(a.json(&["list", "ls"])["data"].as_array().unwrap().len(), 1);
    assert!(
        b.json(&["list", "ls"])["data"]
            .as_array()
            .unwrap()
            .is_empty(),
        "each --db must be a separate database, or tests would interfere"
    );
}

/// Guard against a scenario file drifting from the binary's actual surface.
#[test]
fn every_top_level_subcommand_is_reachable() {
    let cli = Cli::new();
    let help = String::from_utf8_lossy(&cli.raw(&["--help"]).stdout).to_string();

    for expected in [
        "list",
        "site",
        "app",
        "exception",
        "schedule",
        "stats",
        "protect",
        "setting",
        "blocks",
        "pomodoro",
        "allowance",
    ] {
        assert!(
            help.contains(expected),
            "`{expected}` missing from --help output"
        );
    }
}

/// The binary must not need a real Focuser install to run.
#[test]
fn runs_against_a_database_that_does_not_exist_yet() {
    let dir = TempDir::new().unwrap();
    let db = dir.path().join("nested").join("fresh.db");
    std::fs::create_dir_all(db.parent().unwrap()).unwrap();

    let out = Command::new(env!("CARGO_BIN_EXE_focuser-cli"))
        .arg("--db")
        .arg(&db)
        .args(["list", "ls"])
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "a fresh database should be created, got: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(Path::new(&db).exists(), "database file should be created");
}

// ─── Whole configuration ────────────────────────────────────────────

#[test]
fn export_then_import_round_trips_through_a_file() {
    let source = Cli::new();
    let id = source.make_list("Social");
    source.ok(&["site", "add", &id, "reddit.com"]);

    let exported = source.ok(&["config", "export"]);

    let target = Cli::new();
    target.make_list("Replaced");
    let path = target._dir.path().join("config.json");
    std::fs::write(&path, &exported).unwrap();

    target.ok(&["config", "import", path.to_str().unwrap()]);

    let lists = target.json(&["list", "ls"]);
    let data = lists["data"].as_array().unwrap();
    assert_eq!(data.len(), 1, "import replaces rather than merges");
    assert_eq!(data[0]["name"], "Social");
    assert_eq!(data[0]["websites"].as_array().unwrap().len(), 1);
}

#[test]
fn wipe_refuses_without_an_explicit_yes() {
    let cli = Cli::new();
    cli.make_list("Keep me");

    let (code, stderr) = cli.fail(&["config", "wipe"]);
    assert_ne!(code, 0);
    assert!(
        stderr.contains("--yes"),
        "stderr should say what is missing"
    );

    assert_eq!(
        cli.json(&["list", "ls"])["data"].as_array().unwrap().len(),
        1
    );
}

#[test]
fn wipe_with_yes_removes_everything() {
    let cli = Cli::new();
    cli.make_list("Gone");

    cli.ok(&["config", "wipe", "--yes"]);

    assert!(
        cli.json(&["list", "ls"])["data"]
            .as_array()
            .unwrap()
            .is_empty()
    );
}

#[test]
fn import_of_a_junk_file_fails_as_validation_and_changes_nothing() {
    let cli = Cli::new();
    cli.make_list("Keep me");

    let path = cli._dir.path().join("junk.json");
    std::fs::write(&path, "this is not a configuration").unwrap();

    let (code, _) = cli.fail(&["config", "import", path.to_str().unwrap()]);
    assert_eq!(code, 2, "caller error, not a backend fault");
    assert_eq!(
        cli.json(&["list", "ls"])["data"].as_array().unwrap().len(),
        1
    );
}

#[test]
fn wipe_and_import_are_refused_while_a_list_is_locked() {
    let cli = Cli::new();
    let id = cli.make_list("Locked");
    cli.ok(&["protect", "enable", &id, "--minutes", "60"]);

    let path = cli._dir.path().join("empty.json");
    std::fs::write(
        &path,
        r#"{"version":1,"app":"Focuser","exported_at":"2026-07-27T00:00:00Z","block_lists":[]}"#,
    )
    .unwrap();

    assert_eq!(cli.fail(&["config", "wipe", "--yes"]).0, 5);
    assert_eq!(cli.fail(&["config", "import", path.to_str().unwrap()]).0, 5);
    assert_eq!(
        cli.json(&["list", "ls"])["data"].as_array().unwrap().len(),
        1
    );
}

// ─── Diagnostics ────────────────────────────────────────────────────

#[test]
fn a_lock_with_no_end_names_the_flags_it_needs() {
    let cli = Cli::new();
    let id = cli.make_list("Forever");

    let (code, error) = cli.fail(&["protect", "enable", &id, "--until-unlocked"]);

    assert_eq!(code, 2);
    assert!(
        error.contains("--password") && error.contains("--random-text-length"),
        "got: {error}"
    );
    cli.ok(&["list", "disable", &id]);
}

#[test]
fn a_lock_with_no_end_says_so_and_ends_with_its_password() {
    let cli = Cli::new();
    let id = cli.make_list("Forever");
    cli.ok(&[
        "protect",
        "enable",
        &id,
        "--until-unlocked",
        "--password",
        "open-sesame",
    ]);

    assert!(cli.ok(&["protect", "status"]).contains("until unlocked"));
    assert_eq!(cli.fail(&["list", "disable", &id]).0, 5);

    cli.ok(&["protect", "unlock", &id, "open-sesame"]);
    cli.ok(&["list", "disable", &id]);
}

#[test]
fn explicit_password_arguments_still_enable_and_unlock_protection() {
    let cli = Cli::new();
    let id = cli.make_list("Password protected");
    let password = "compatibility-test-secret";
    cli.ok(&["protect", "enable", &id, "--password", password]);

    let wrong = cli.raw(&["protect", "unlock", &id, "wrong-secret"]);
    assert!(!wrong.status.success());
    assert!(!String::from_utf8_lossy(&wrong.stderr).contains("wrong-secret"));
    assert_eq!(cli.fail(&["list", "disable", &id]).0, 5);

    cli.ok(&["protect", "unlock", &id, password]);
    cli.ok(&["list", "disable", &id]);
}

#[test]
fn password_prompt_without_a_terminal_does_not_enable_protection() {
    let cli = Cli::new();
    let id = cli.make_list("Interactive only");
    let (code, error) = cli.fail(&["protect", "enable", &id, "--password"]);
    assert_eq!(code, 1);
    assert!(error.contains("interactive terminal"), "got: {error}");
    cli.ok(&["list", "disable", &id]);
}

#[test]
fn unlock_prompt_without_a_terminal_preserves_protection_and_json_stdout() {
    let cli = Cli::new();
    let id = cli.make_list("Still protected");
    cli.ok(&["protect", "enable", &id, "--password", "test-secret"]);
    let out = cli.raw(&["--json", "protect", "unlock", &id]);
    assert_eq!(out.status.code(), Some(1));
    assert!(out.stdout.is_empty());
    assert!(String::from_utf8_lossy(&out.stderr).contains("interactive terminal"));
    assert_eq!(cli.fail(&["list", "disable", &id]).0, 5);
}

#[test]
fn prompted_password_conflicts_with_random_text() {
    let cli = Cli::new();
    let id = cli.make_list("One unlock method");
    let (code, error) = cli.fail(&[
        "protect",
        "enable",
        &id,
        "--password",
        "--random-text-length",
        "20",
    ]);
    assert_eq!(code, 2);
    assert!(error.contains("cannot be used with"), "got: {error}");
    cli.ok(&["list", "disable", &id]);
}

#[test]
fn check_reports_whether_a_domain_is_blocked() {
    let cli = Cli::new();
    let id = cli.make_list("Social");
    cli.ok(&["site", "add", &id, "reddit.com"]);

    assert_eq!(cli.json(&["check", "reddit.com"])["data"], true);
    assert_eq!(cli.json(&["check", "www.reddit.com"])["data"], true);
    assert_eq!(cli.json(&["check", "example.com"])["data"], false);

    cli.ok(&["list", "disable", &id]);
    assert_eq!(cli.json(&["check", "reddit.com"])["data"], false);
}

#[test]
fn browsers_lists_every_known_browser() {
    let cli = Cli::new();
    let browsers = cli.json(&["browsers"]);

    assert_eq!(browsers["kind"], "browser_status");
    assert!(!browsers["data"].as_array().unwrap().is_empty());
}

#[test]
fn version_matches_the_binary() {
    let cli = Cli::new();
    assert_eq!(cli.ok(&["version"]), env!("CARGO_PKG_VERSION"));
}

#[test]
fn a_locked_focus_session_cannot_be_stopped_from_the_command_line() {
    let cli = Cli::new();
    let list = cli.json(&["list", "create", "Focus"])["data"]["id"]
        .as_str()
        .expect("list id")
        .to_string();

    cli.ok(&["pomodoro", "start", &list, "--lock-rounds", "2"]);
    assert!(
        cli.ok(&["pomodoro", "status"])
            .contains("Locked for 2 more rounds")
    );
    for refused in ["stop", "skip", "pause"] {
        let (code, stderr) = cli.fail(&["pomodoro", refused]);
        assert_eq!(
            code, 5,
            "`pomodoro {refused}` should be refused as protected"
        );
        assert!(stderr.starts_with("protected:"), "got: {stderr}");
    }

    let (code, _) = cli.fail(&["pomodoro", "start", &list, "--lock-rounds", "13"]);
    assert_eq!(code, 2, "more than 12 rounds is invalid");
}