romm-cli 0.40.0

Rust-based CLI and TUI for the ROMM API
Documentation
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use std::path::PathBuf;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

use crate::config::{default_theme_id, normalize_romm_origin, AuthConfig};
use crate::core::download::validate_configured_download_directory;
use crate::tui::path_picker::{PathPicker, PathPickerMode};
use crate::tui::theme::{resolve_theme_or_default, RommStyles};

use super::types::{AuthKind, Step};
use super::SetupWizard;

fn unique_test_download_dir() -> PathBuf {
    let suffix = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    std::env::temp_dir().join(format!("romm-dl-test-{}-{suffix}", std::process::id()))
}

fn wizard_with_pairing(mock_uri: &str, code: &str, download_dir: &str) -> SetupWizard {
    SetupWizard {
        step: Step::PairingCode,
        auth_kind: AuthKind::Pairing,
        auth_menu_selected: 4,
        url: mock_uri.to_string(),
        url_cursor: mock_uri.len(),
        download_picker: PathPicker::new(PathPickerMode::Directory, download_dir),
        username: String::new(),
        user_cursor: 0,
        password: String::new(),
        bearer_token: String::new(),
        bearer_cursor: 0,
        api_header: String::new(),
        header_cursor: 0,
        api_key: String::new(),
        api_key_cursor: 0,
        pairing_code: code.to_string(),
        pairing_cursor: code.len(),
        reuse_keyring_password: false,
        reuse_keyring_bearer: false,
        reuse_keyring_api_key: false,
        testing: false,
        use_https: false,
        skip_custom_console_paths: false,
        error: None,
    }
}

#[tokio::test]
async fn pairing_config_from_exchange_returns_bearer_token() {
    let mock_server = MockServer::start().await;

    let token_json = serde_json::json!({
        "id": 1,
        "name": "cli-device",
        "scopes": [],
        "expires_at": null,
        "last_used_at": null,
        "created_at": "2020-01-01T00:00:00Z",
        "user_id": 42,
        "raw_token": "exchanged-bearer-secret"
    });

    Mock::given(method("POST"))
        .and(path("/api/client-tokens/exchange"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&token_json))
        .mount(&mock_server)
        .await;

    let uri = mock_server.uri();
    let download_dir = unique_test_download_dir();
    let download_dir = download_dir.to_string_lossy().into_owned();
    let wizard = wizard_with_pairing(&uri, "ABCD1234", &download_dir);
    let cfg = wizard
        .pairing_config_from_exchange(false)
        .await
        .expect("pairing exchange should succeed");

    match cfg.auth {
        Some(AuthConfig::Bearer { token }) => {
            assert_eq!(token, "exchanged-bearer-secret");
        }
        _ => panic!("expected bearer auth after pairing exchange"),
    }
    assert_eq!(cfg.base_url, normalize_romm_origin(&uri));
    let expected_download_dir = validate_configured_download_directory(&download_dir).unwrap();
    assert_eq!(
        cfg.download_dir,
        expected_download_dir.display().to_string()
    );
}

#[test]
fn hidden_password_field_does_not_render_inline_cursor_glyph() {
    let mut wizard = SetupWizard::new();
    wizard.step = Step::BasicPass;
    wizard.password = "secret".to_string();
    let backend = TestBackend::new(80, 24);
    let mut terminal = Terminal::new(backend).expect("create test terminal");
    let theme = resolve_theme_or_default(&default_theme_id());
    let styles = RommStyles::new(theme.as_ref());
    terminal
        .draw(|frame| {
            let area = frame.area();
            wizard.render(frame, area, &styles);
        })
        .expect("render setup wizard");
    let backend = terminal.backend();
    let buffer = backend.buffer();
    let has_cursor_glyph = buffer.content().iter().any(|cell| cell.symbol() == "");
    assert!(
        !has_cursor_glyph,
        "password field should rely on terminal cursor, not inline glyph"
    );
}

#[test]
fn hidden_api_key_field_does_not_render_inline_cursor_glyph() {
    let mut wizard = SetupWizard::new();
    wizard.step = Step::ApiKey;
    wizard.api_key = "secret-key".to_string();
    wizard.api_key_cursor = wizard.api_key.len();
    let backend = TestBackend::new(80, 24);
    let mut terminal = Terminal::new(backend).expect("create test terminal");
    let theme = resolve_theme_or_default(&default_theme_id());
    let styles = RommStyles::new(theme.as_ref());
    terminal
        .draw(|frame| {
            let area = frame.area();
            wizard.render(frame, area, &styles);
        })
        .expect("render setup wizard");
    let backend = terminal.backend();
    let buffer = backend.buffer();
    let has_cursor_glyph = buffer.content().iter().any(|cell| cell.symbol() == "");
    assert!(
        !has_cursor_glyph,
        "API key field should rely on terminal cursor, not inline glyph"
    );
}