wekan-cli 2.1.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use std::{
    cell::{Cell, RefCell},
    time::Duration,
};

use super::cache::CacheEntry;
use super::*;
use crate::input::FakeConfirmationProvider;
use wiremock::{
    Mock, MockServer, ResponseTemplate,
    matchers::{method, path},
};

struct FixedClock(u64);
impl Clock for FixedClock {
    fn now(&self) -> u64 {
        self.0
    }
}

struct Releases {
    response: Result<Release, AppError>,
    requests: RefCell<Vec<Duration>>,
}
impl ReleaseProvider for Releases {
    async fn latest(&self, timeout: Duration) -> Result<Release, AppError> {
        self.requests.borrow_mut().push(timeout);
        self.response.clone()
    }
}

struct Installation {
    method: InstallationMethod,
    installed: Cell<bool>,
    fail: bool,
}
impl Installer for Installation {
    fn method(&self) -> InstallationMethod {
        self.method
    }
    async fn install(&self, _: &Release) -> Result<(), AppError> {
        self.installed.set(true);
        if self.fail {
            Err(update_failed("test installation failed"))
        } else {
            Ok(())
        }
    }
}

#[derive(Default)]
struct Cache {
    entry: CacheEntry,
    fail_load: bool,
    fail_save: bool,
}
impl UpdateCache for Cache {
    fn load(&self) -> Result<CacheEntry, AppError> {
        if self.fail_load {
            Err(file_error("load", "test failure"))
        } else {
            Ok(self.entry.clone())
        }
    }
    fn save(&mut self, entry: &CacheEntry) -> Result<(), AppError> {
        if self.fail_save {
            return Err(file_error("save", "test failure"));
        }
        self.entry = entry.clone();
        Ok(())
    }
}

pub(super) fn release(version: &str) -> Release {
    Release {
        tag: format!("v{version}"),
        version: semver::Version::parse(version).unwrap(),
        assets: vec![],
    }
}

fn service(version: &str) -> UpdateService<Releases, Installation, FixedClock> {
    UpdateService {
        http: Releases {
            response: Ok(release(version)),
            requests: RefCell::default(),
        },
        installer: Installation {
            method: InstallationMethod::Release,
            installed: Cell::new(false),
            fail: false,
        },
        clock: FixedClock(200_000),
        current_version: "2.0.0".to_owned(),
    }
}

#[test]
fn startup_requires_human_output_and_all_terminal_streams_without_opt_out_or_ci() {
    fn eligible() -> StartupEligibility {
        StartupEligibility {
            human: true,
            stdin_terminal: true,
            stdout_terminal: true,
            stderr_terminal: true,
            ci: false,
            disabled: false,
            self_command: false,
        }
    }
    assert!(eligible().eligible());
    for field in 0..7 {
        let mut gate = eligible();
        match field {
            0 => gate.human = false,
            1 => gate.stdin_terminal = false,
            2 => gate.stdout_terminal = false,
            3 => gate.stderr_terminal = false,
            4 => gate.ci = true,
            5 => gate.disabled = true,
            _ => gate.self_command = true,
        }
        assert!(!gate.eligible(), "field {field}");
    }
}

#[tokio::test]
async fn checks_compare_semver_and_never_downgrade_or_treat_build_metadata_as_newer() {
    for (version, expected) in [
        ("1.99.0", SelfUpdateStatus::UpToDate),
        ("2.0.0", SelfUpdateStatus::UpToDate),
        ("2.0.0+build.1", SelfUpdateStatus::UpToDate),
        ("2.0.1", SelfUpdateStatus::Available),
        ("10.0.0", SelfUpdateStatus::Available),
    ] {
        let service = service(version);
        let confirmation = FakeConfirmationProvider::accepting();
        let ManualOutcome::Complete(data) =
            service.manual(true, false, &confirmation).await.unwrap()
        else {
            panic!()
        };
        assert_eq!(data.status, expected);
        assert!(!service.installer.installed.get());
        assert!(confirmation.requests().is_empty());
        assert_eq!(*service.http.requests.borrow(), [MANUAL_TIMEOUT]);
    }
}

#[tokio::test]
async fn manual_confirmation_cancellation_yes_and_unknown_installation() {
    let service = service("2.1.0");
    let no = FakeConfirmationProvider::declining();
    assert!(matches!(
        service.manual(false, false, &no).await.unwrap(),
        ManualOutcome::Cancelled
    ));
    assert!(!service.installer.installed.get());
    assert!(no.requests()[0].prompt().contains("2.0.0 → 2.1.0"));
    let never = FakeConfirmationProvider::failing(AppError::invalid_input("must not prompt"));
    let ManualOutcome::Complete(data) = service.manual(false, true, &never).await.unwrap() else {
        panic!()
    };
    assert_eq!(data.status, SelfUpdateStatus::Updated);
    assert!(service.installer.installed.get());
    assert!(never.requests().is_empty());
    let mut unknown = super::tests::service("2.1.0");
    unknown.installer.method = InstallationMethod::Unknown;
    assert_eq!(
        unknown
            .manual(false, true, &never)
            .await
            .unwrap_err()
            .code(),
        ErrorCode::UpdateFailed
    );
    assert!(unknown.manual(true, false, &never).await.is_ok());
    assert!(!unknown.installer.installed.get());
}

#[tokio::test]
async fn automatic_decline_continues_and_acceptance_stops_the_original_command() {
    for accepted in [false, true] {
        let service = service("2.1.0");
        let confirmation = if accepted {
            FakeConfirmationProvider::accepting()
        } else {
            FakeConfirmationProvider::declining()
        };
        let mut cache = Cache::default();
        let outcome = service.automatic(&mut cache, &confirmation).await.unwrap();
        assert_eq!(outcome.should_exit(), accepted);
        assert_eq!(service.installer.installed.get(), accepted);
        assert_eq!(confirmation.requests().len(), 1);
        assert_eq!(*service.http.requests.borrow(), [AUTOMATIC_TIMEOUT]);
        assert_eq!(cache.entry.last_notified, Some(service.clock.now()));
        assert!(
            !service
                .automatic(&mut cache, &confirmation)
                .await
                .unwrap()
                .should_exit()
        );
        assert_eq!(confirmation.requests().len(), 1);
        assert_eq!(service.http.requests.borrow().len(), 1);
    }
}

#[tokio::test]
async fn automatic_failures_are_quiet_but_accepted_installation_failure_is_returned() {
    for failure in 0..3 {
        let mut service = service("2.1.0");
        let mut cache = Cache::default();
        match failure {
            0 => service.http.response = Err(protocol("offline")),
            1 => cache.fail_load = true,
            _ => cache.fail_save = true,
        }
        let confirmation = FakeConfirmationProvider::accepting();
        assert!(matches!(
            service.automatic(&mut cache, &confirmation).await.unwrap(),
            StartupUpdateOutcome::Continue
        ));
        assert!(confirmation.requests().is_empty());
        assert!(!service.installer.installed.get());
        if failure == 0 {
            assert!(matches!(
                service.automatic(&mut cache, &confirmation).await.unwrap(),
                StartupUpdateOutcome::Continue
            ));
            assert_eq!(service.http.requests.borrow().len(), 1);
        }
    }
    let mut service = service("2.1.0");
    service.installer.fail = true;
    let error = service
        .automatic(
            &mut Cache::default(),
            &FakeConfirmationProvider::accepting(),
        )
        .await
        .unwrap_err();
    assert_eq!(error.code(), ErrorCode::UpdateFailed);
    assert_eq!(error.exit_code(), StableExitCode::Transport);
}

#[tokio::test]
async fn cache_expires_at_24_hours_and_future_timestamps_are_rechecked() {
    for timestamp in [200_000 - CHECK_INTERVAL, 200_001] {
        let service = service("2.1.0");
        let mut cache = Cache {
            entry: CacheEntry {
                last_attempt: Some(timestamp),
                last_notified: Some(timestamp),
                release: Some(release("2.0.0")),
            },
            ..Cache::default()
        };
        let no = FakeConfirmationProvider::declining();
        service.automatic(&mut cache, &no).await.unwrap();
        assert_eq!(service.http.requests.borrow().len(), 1);
        assert_eq!(no.requests().len(), 1);
    }
}

#[tokio::test]
async fn fresh_cached_availability_is_used_without_network_and_unknown_installs_only_get_a_notice()
{
    let mut service = service("2.1.0");
    service.installer.method = InstallationMethod::Unknown;
    let mut cache = Cache {
        entry: CacheEntry {
            last_attempt: Some(199_999),
            last_notified: None,
            release: Some(release("2.1.0")),
        },
        ..Cache::default()
    };
    let no = FakeConfirmationProvider::accepting();
    let outcome = service.automatic(&mut cache, &no).await.unwrap();
    assert!(!outcome.should_exit());
    let StartupUpdateOutcome::Notice(data) = outcome else {
        panic!("unknown installations receive a notice");
    };
    assert!(service.http.requests.borrow().is_empty());
    assert!(no.requests().is_empty());
    assert_eq!(data.current_version, "2.0.0");
    assert_eq!(data.latest_version, "2.1.0");
    assert_eq!(data.installation_method, InstallationMethod::Unknown);
}

#[test]
fn disk_cache_is_atomic_recovers_from_invalid_data_and_excludes_concurrent_starts() {
    let dir = tempfile::tempdir().unwrap();
    let mut cache = FileCache::open(dir.path().to_owned()).unwrap();
    assert!(FileCache::open(dir.path().to_owned()).is_err());
    assert!(cache.load().unwrap().last_attempt.is_none());
    std::fs::write(dir.path().join("check.json"), "invalid").unwrap();
    assert!(cache.load().unwrap().last_attempt.is_none());
    let entry = CacheEntry {
        last_attempt: Some(42),
        last_notified: Some(42),
        release: Some(release("2.1.0")),
    };
    cache.save(&entry).unwrap();
    cache.save(&entry).unwrap();
    assert_eq!(cache.load().unwrap().last_notified, Some(42));
    drop(cache);
    assert!(FileCache::open(dir.path().to_owned()).is_ok());
}

fn github_json(tag: &str) -> serde_json::Value {
    serde_json::json!({"tag_name": tag, "draft": false, "prerelease": false, "assets": [], "unrelated_github_field": true})
}

#[tokio::test]
async fn github_discovery_handles_tags_invalid_responses_and_http_errors() {
    for (tag, draft, prerelease, status) in [
        ("v2.1.0", false, false, 200),
        ("2.1.0", false, false, 200),
        ("v2.2.0-rc.1", false, false, 200),
        ("v2.1.0", true, false, 200),
        ("v2.1.0", false, true, 200),
        ("invalid", false, false, 200),
        ("v2.1.0", false, false, 403),
        ("v2.1.0", false, false, 429),
        ("v2.1.0", false, false, 404),
    ] {
        let server = MockServer::start().await;
        let mut body = github_json(tag);
        body["draft"] = draft.into();
        body["prerelease"] = prerelease.into();
        Mock::given(method("GET"))
            .and(path("/latest"))
            .respond_with(ResponseTemplate::new(status).set_body_json(body))
            .expect(1)
            .mount(&server)
            .await;
        let http = HttpReleases {
            client: reqwest::Client::new(),
            endpoint: format!("{}/latest", server.uri()),
        };
        let result = http.latest(Duration::from_secs(1)).await;
        if status == 200 && !draft && !prerelease && matches!(tag, "v2.1.0" | "2.1.0") {
            assert_eq!(result.unwrap().version.to_string(), "2.1.0");
        } else {
            assert!(result.is_err(), "{tag}, {status}");
        }
    }
}

#[tokio::test]
async fn discovery_timeout_is_bounded_and_errors_do_not_include_response_bodies() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(github_json("v2.1.0"))
                .set_delay(Duration::from_secs(1)),
        )
        .mount(&server)
        .await;
    let http = HttpReleases {
        client: reqwest::Client::new(),
        endpoint: server.uri(),
    };
    let start = std::time::Instant::now();
    let error = http.latest(Duration::from_millis(20)).await.err().unwrap();
    assert_eq!(error.code(), ErrorCode::TransportError);
    assert!(error.message().contains("timed out"));
    assert!(start.elapsed() < Duration::from_secs(1));
}