wekan-cli 0.12.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use crate::{
    cli::Cli,
    client::WekanClientFactory,
    command_result::{CommandSuccess, DestructiveOperation},
    commands::RootCommand,
    credentials::{
        CredentialCreateOutcome, CredentialDeleteOutcome, CredentialError, CredentialRecord,
        CredentialStore, CredentialTarget,
    },
    error::{AppError, ErrorCode},
    input::{ConfirmationProvider, ConfirmationRequest, FakeConfirmationProvider},
    output::{OutputFormat, render_success},
};
use clap::Parser;
use secrecy::SecretString;
use serde_json::{Value, json};
use std::sync::Mutex;
use time::{Duration, OffsetDateTime};
use wiremock::{
    Mock, MockServer, ResponseTemplate,
    matchers::{body_json, header, method, path},
};

struct Store {
    record: Mutex<Option<CredentialRecord>>,
}
impl Store {
    fn new(server: &MockServer) -> Self {
        Self {
            record: Mutex::new(Some(CredentialRecord::new(
                format!("{}/", server.uri()),
                "user".into(),
                SecretString::from("dependency-token".to_owned()),
                OffsetDateTime::now_utc() + Duration::hours(1),
            ))),
        }
    }
}
impl CredentialStore for Store {
    fn check_available(&self, _: &CredentialTarget) -> Result<(), CredentialError> {
        Ok(())
    }
    fn exists(&self, _: &CredentialTarget) -> Result<bool, CredentialError> {
        Ok(self.record.lock().unwrap().is_some())
    }
    fn load(&self, _: &CredentialTarget) -> Result<Option<CredentialRecord>, CredentialError> {
        Ok(self.record.lock().unwrap().clone())
    }
    fn create(
        &self,
        _: &CredentialTarget,
        _: &CredentialRecord,
    ) -> Result<CredentialCreateOutcome, CredentialError> {
        panic!("must not store credentials")
    }
    fn delete(&self, _: &CredentialTarget) -> Result<bool, CredentialError> {
        panic!("must not delete credentials")
    }
    fn delete_if_matches(
        &self,
        _: &CredentialTarget,
        _: &CredentialRecord,
    ) -> Result<CredentialDeleteOutcome, CredentialError> {
        panic!("must not delete credentials")
    }
}
async fn run(
    server: &MockServer,
    store: &Store,
    args: &[&str],
    confirmation: &dyn ConfirmationProvider,
) -> Result<CommandSuccess, AppError> {
    let mut argv = vec!["wekan", "dependency"];
    argv.extend_from_slice(args);
    argv.extend(["--board", "b"]);
    let RootCommand::Dependency(args) = Cli::try_parse_from(argv).unwrap().command else {
        unreachable!()
    };
    super::dispatch(
        args.command,
        &WekanClientFactory::for_profile(format!("{}/", server.uri()), "default".into(), false),
        store,
        confirmation,
    )
    .await
}
fn data(result: &CommandSuccess) -> Value {
    serde_json::from_str::<Value>(&render_success(OutputFormat::Json, result)).unwrap()["data"]
        .clone()
}

#[tokio::test]
async fn dependency_dispatch_maps_all_outputs_without_extra_http() {
    let server = MockServer::start().await;
    let cases = [
        (
            vec!["list", "--card", "c"],
            "GET",
            "/api/boards/b/cards/c/dependencies",
            json!([{"cardId":"t", "type":"blocks", "color":"red", "icon":"lock"}]),
            None,
            json!({"board_id":"b", "card_id":"c", "dependencies":[{"target_card_id":"t", "dependency_type":"blocks", "color":"red", "icon":"lock"}]}),
        ),
        (
            vec!["list"],
            "GET",
            "/api/boards/b/dependencies",
            json!([{"from":"c", "to":"t", "type":"fixes", "color":"red", "icon":"bug"}]),
            None,
            json!({"board_id":"b", "dependencies":[{"source_card_id":"c", "source_title":null, "source_card_number":null, "target_card_id":"t", "target_title":null, "target_card_number":null, "dependency_type":"fixes", "color":"red", "icon":"bug"}]}),
        ),
        (
            vec![
                "create",
                "t",
                "--card",
                "c",
                "--null-type",
                "--color",
                "",
                "--icon",
                " link ",
            ],
            "POST",
            "/api/boards/b/cards/c/dependencies",
            json!({"_id":"c", "cardId":"t", "type":"related-to", "color":"red", "icon":" link "}),
            Some(json!({"cardId":"t", "type":null, "color":"", "icon":" link "})),
            json!({"board_id":"b", "card_id":"c", "target_card_id":"t", "dependency_type":"related-to", "color":"red", "icon":" link "}),
        ),
        (
            vec!["update", "t", "--card", "c", "--type", "", "--null-color"],
            "PUT",
            "/api/boards/b/cards/c/dependencies/t",
            json!({"_id":"c"}),
            Some(json!({"type":"", "color":null})),
            json!({"board_id":"b", "card_id":"c", "target_card_id":"t", "submitted_fields":["type", "color"], "mutation_confirmed":false}),
        ),
        (
            vec!["delete", "t", "--card", "c", "--yes"],
            "DELETE",
            "/api/boards/b/cards/c/dependencies/t",
            json!({"_id":"c"}),
            None,
            json!({"board_id":"b", "card_id":"c", "target_card_id":"t", "deletion_requested":true, "mutation_confirmed":false}),
        ),
    ];
    for (args, verb, url, response, body, expected) in cases {
        server.reset().await;
        let mut mock = Mock::given(method(verb))
            .and(path(url))
            .and(header("authorization", "Bearer dependency-token"));
        if let Some(body) = body {
            mock = mock.and(body_json(body));
        }
        mock.respond_with(ResponseTemplate::new(200).set_body_json(response))
            .expect(1)
            .mount(&server)
            .await;
        let result = run(
            &server,
            &Store::new(&server),
            &args,
            &FakeConfirmationProvider::declining(),
        )
        .await
        .unwrap();
        assert_eq!(data(&result), expected);
        assert!(!render_success(OutputFormat::Human, &result).is_empty());
        assert_eq!(server.received_requests().await.unwrap().len(), 1);
        server.verify().await;
    }
}

struct ChangingConfirmation<'a> {
    store: &'a Store,
    action: &'static str,
}
impl ConfirmationProvider for ChangingConfirmation<'_> {
    fn confirm(&self, _: &ConfirmationRequest) -> Result<bool, AppError> {
        let mut record = self.store.record.lock().unwrap();
        match self.action {
            "missing" => *record = None,
            "changed" => {
                let old = record.as_ref().unwrap();
                *record = Some(CredentialRecord::new(
                    old.server_url().into(),
                    "other-user".into(),
                    SecretString::from("other-token".to_owned()),
                    old.token_expires(),
                ));
            }
            _ => unreachable!(),
        }
        Ok(true)
    }
}

#[tokio::test]
async fn dependency_deletion_cancels_or_rejects_changed_and_missing_credentials() {
    let server = MockServer::start().await;
    let args = ["delete", "t", "--card", "c"];
    let confirmation = FakeConfirmationProvider::declining();
    let result = run(&server, &Store::new(&server), &args, &confirmation)
        .await
        .unwrap();
    let CommandSuccess::Cancelled(cancelled) = result else {
        panic!("expected cancellation")
    };
    assert_eq!(cancelled.operation, DestructiveOperation::DependencyDelete);
    assert!(
        confirmation.requests()[0]
            .prompt()
            .contains("card `c` to card `t`")
    );
    for (action, code) in [
        ("missing", ErrorCode::CredentialNotFound),
        ("changed", ErrorCode::CredentialStoreFailed),
    ] {
        let store = Store::new(&server);
        let error = run(
            &server,
            &store,
            &args,
            &ChangingConfirmation {
                store: &store,
                action,
            },
        )
        .await
        .unwrap_err();
        assert_eq!(error.code(), code);
    }
    assert!(server.received_requests().await.unwrap().is_empty());
}

#[tokio::test]
async fn dependency_deletion_rechecks_expiry_after_confirmation() {
    struct ExpiringConfirmation {
        expires: OffsetDateTime,
        called: std::sync::atomic::AtomicBool,
    }
    impl ConfirmationProvider for ExpiringConfirmation {
        fn confirm(&self, _: &ConfirmationRequest) -> Result<bool, AppError> {
            self.called.store(true, std::sync::atomic::Ordering::SeqCst);
            while OffsetDateTime::now_utc() < self.expires {
                std::thread::sleep(std::time::Duration::from_millis(10));
            }
            Ok(true)
        }
    }
    let server = MockServer::start().await;
    let store = Store::new(&server);
    let expires = OffsetDateTime::now_utc() + Duration::seconds(2);
    *store.record.lock().unwrap() = Some(CredentialRecord::new(
        format!("{}/", server.uri()),
        "user".into(),
        SecretString::from("dependency-token".to_owned()),
        expires,
    ));
    let confirmation = ExpiringConfirmation {
        expires,
        called: std::sync::atomic::AtomicBool::new(false),
    };
    let error = run(
        &server,
        &store,
        &["delete", "t", "--card", "c"],
        &confirmation,
    )
    .await
    .unwrap_err();
    assert_eq!(error.code(), ErrorCode::CredentialExpired);
    assert!(
        confirmation
            .called
            .load(std::sync::atomic::Ordering::SeqCst)
    );
    assert!(server.received_requests().await.unwrap().is_empty());
}

#[tokio::test]
async fn dependency_errors_preserve_status_redact_secrets_and_report_mutation_outcomes() {
    let server = MockServer::start().await;
    Mock::given(method("PUT")).and(body_json(json!({}))).respond_with(ResponseTemplate::new(500).set_body_json(json!({"error":"bad-request", "reason":"dependency-token cannot update", "errorType":"Meteor.Error", "isClientSafe":true, "message":"dependency-token cannot update"}))).expect(1).mount(&server).await;
    let error = run(
        &server,
        &Store::new(&server),
        &["update", "t", "--card", "c"],
        &FakeConfirmationProvider::declining(),
    )
    .await
    .unwrap_err();
    assert_eq!(error.details().http_status, Some(500));
    assert!(!format!("{error:?}").contains("dependency-token"));
    server.verify().await;
    server.reset().await;
    Mock::given(method("PUT"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({"_id":"c", "unmapped":true})))
        .mount(&server)
        .await;
    let error = run(
        &server,
        &Store::new(&server),
        &["update", "t", "--card", "c", "--color", "red"],
        &FakeConfirmationProvider::declining(),
    )
    .await
    .unwrap_err();
    assert_eq!(error.code(), ErrorCode::ProtocolError);
    assert_eq!(error.details().outcome_unknown, Some(true));
}

#[test]
fn dependency_renderers_escape_terminal_controls_and_preserve_optional_numbers() {
    use crate::command_result::{BoardDependencyCollectionSuccess, DependencyLine};
    let result = CommandSuccess::BoardDependencies(BoardDependencyCollectionSuccess {
        board_id: "b".into(),
        dependencies: vec![DependencyLine {
            source_card_id: "c\n".into(),
            source_title: Some("Title\u{1b}".into()),
            source_card_number: Some(serde_json::Number::from(9007199254740993_u64)),
            target_card_id: "t".into(),
            target_title: None,
            target_card_number: None,
            dependency_type: "blocks".into(),
            color: "red".into(),
            icon: "lock".into(),
        }],
    });
    let human = render_success(OutputFormat::Human, &result);
    assert!(!human.contains('\u{1b}'));
    assert!(human.contains("9007199254740993"));
    assert_eq!(
        data(&result)["dependencies"][0]["source_title"],
        "Title\u{1b}"
    );
}