koban-cli 0.3.0

A Rust CLI for Invoice Ninja, built for humans and AI agents
use super::*;

#[tokio::test]
async fn utility_defaults_to_safe_ping_get() {
    let config = Config::from_values("http://localhost:1234", "token").expect("config");
    let output = execute_with_config(
        Cli {
            output: OutputFormat::Json,
            command: Some(Commands::Utility(EndpointCommand::Run(EndpointArgs {
                endpoint: None,
                method: None,
                payload: empty_resource_payload_args(),
                safety: WriteSafetyArgs {
                    dry_run: true,
                    yes: false,
                },
                include: Vec::new(),
            }))),
        },
        config,
    )
    .await
    .expect("utility ping dry run");
    assert!(output.contains("\"method\": \"GET\""), "got: {output}");
    assert!(
        output.contains("\"path\": \"api/v1/ping\""),
        "got: {output}"
    );
}

#[tokio::test]
async fn reports_default_to_post() {
    let config = Config::from_values("http://localhost:1234", "token").expect("config");
    let output = execute_with_config(
        Cli {
            output: OutputFormat::Json,
            command: Some(Commands::Reports(EndpointCommand::Run(EndpointArgs {
                endpoint: None,
                method: None,
                payload: {
                    let mut args = empty_resource_payload_args();
                    args.fields.push("date_range=last30".to_string());
                    args
                },
                safety: WriteSafetyArgs {
                    dry_run: true,
                    yes: false,
                },
                include: Vec::new(),
            }))),
        },
        config,
    )
    .await
    .expect("report default dry run");
    assert!(output.contains("\"method\": \"POST\""), "got: {output}");
    assert!(
        output.contains("\"path\": \"api/v1/reports\""),
        "got: {output}"
    );
}

#[tokio::test]
async fn endpoint_invalid_paths_fail_before_network() {
    let config = Config::from_values("http://localhost:1234", "token").expect("config");

    let error = execute_with_config(
        Cli {
            output: OutputFormat::Json,
            command: Some(Commands::Utility(EndpointCommand::Run(EndpointArgs {
                endpoint: Some("../admin".to_string()),
                method: Some(HttpMethod::Get),
                payload: empty_resource_payload_args(),
                safety: WriteSafetyArgs {
                    dry_run: true,
                    yes: false,
                },
                include: Vec::new(),
            }))),
        },
        config,
    )
    .await
    .expect_err("route-changing endpoint path should fail");
    assert!(matches!(error, KobanError::InvalidRequest { .. }));
    assert!(
        error.to_string().contains("relative /api/v1 path"),
        "got: {error}"
    );
}

#[tokio::test]
async fn endpoint_get_and_delete_reject_payloads_instead_of_dropping_them() {
    let config = Config::from_values("http://localhost:1234", "token").expect("config");

    for method in [HttpMethod::Get, HttpMethod::Delete] {
        let error = execute_with_config(
            Cli {
                output: OutputFormat::Json,
                command: Some(Commands::Reports(EndpointCommand::Run(EndpointArgs {
                    endpoint: None,
                    method: Some(method),
                    payload: {
                        let mut args = empty_resource_payload_args();
                        args.fields.push("query=acme".to_string());
                        args
                    },
                    safety: WriteSafetyArgs {
                        dry_run: true,
                        yes: false,
                    },
                    include: Vec::new(),
                }))),
            },
            config.clone(),
        )
        .await
        .expect_err("bodyless endpoint method should reject payload");
        assert!(matches!(error, KobanError::InvalidRequest { .. }));
        assert!(error.to_string().contains(method.label()), "got: {error}");
    }
}

#[tokio::test]
async fn endpoint_post_and_delete_hit_live_routes_when_confirmed() {
    let server = MockServer::start();
    let config = Config::from_values(server.base_url(), "token").expect("config");

    let report_post = server.mock(|when, then| {
        when.method(POST).path("/api/v1/reports/invoices");
        then.status(200)
            .json_body(serde_json::json!({"data": {"name": "invoices"}}));
    });
    let report_delete = server.mock(|when, then| {
        when.method(httpmock::Method::DELETE)
            .path("/api/v1/reports");
        then.status(200)
            .json_body(serde_json::json!({"data": {"deleted": true}}));
    });

    execute_with_config(
        Cli {
            output: OutputFormat::Json,
            command: Some(Commands::Reports(EndpointCommand::Run(EndpointArgs {
                endpoint: Some("reports/invoices".to_string()),
                method: Some(HttpMethod::Post),
                payload: {
                    let mut args = empty_resource_payload_args();
                    args.fields.push("date_range=last30".to_string());
                    args
                },
                safety: WriteSafetyArgs {
                    dry_run: false,
                    yes: true,
                },
                include: Vec::new(),
            }))),
        },
        config.clone(),
    )
    .await
    .expect("report post");

    execute_with_config(
        Cli {
            output: OutputFormat::Json,
            command: Some(Commands::Reports(EndpointCommand::Run(EndpointArgs {
                endpoint: None,
                method: Some(HttpMethod::Delete),
                payload: empty_resource_payload_args(),
                safety: WriteSafetyArgs {
                    dry_run: false,
                    yes: true,
                },
                include: Vec::new(),
            }))),
        },
        config,
    )
    .await
    .expect("report delete");

    report_post.assert();
    report_delete.assert();
}

#[tokio::test]
async fn utility_rejects_write_methods() {
    let config = Config::from_values("http://localhost:1234", "token").expect("config");

    for method in [HttpMethod::Post, HttpMethod::Put, HttpMethod::Delete] {
        let error = execute_with_config(
            Cli {
                output: OutputFormat::Json,
                command: Some(Commands::Utility(EndpointCommand::Run(EndpointArgs {
                    endpoint: Some("support/ticket_1".to_string()),
                    method: Some(method),
                    payload: empty_resource_payload_args(),
                    safety: WriteSafetyArgs {
                        dry_run: false,
                        yes: true,
                    },
                    include: Vec::new(),
                }))),
            },
            config.clone(),
        )
        .await
        .expect_err("utility writes should be rejected");
        assert!(matches!(error, KobanError::InvalidRequest { .. }));
        assert!(error.to_string().contains("read-only"), "got: {error}");
    }
}

#[tokio::test]
async fn scoped_report_and_chart_endpoint_overrides_allow_payload_methods() {
    let config = Config::from_values("http://localhost:1234", "token").expect("config");

    let report = execute_with_config(
        Cli {
            output: OutputFormat::Json,
            command: Some(Commands::Reports(EndpointCommand::Run(EndpointArgs {
                endpoint: Some("reports/invoices".to_string()),
                method: Some(HttpMethod::Post),
                payload: {
                    let mut args = empty_resource_payload_args();
                    args.fields.push("date_range=last30".to_string());
                    args
                },
                safety: WriteSafetyArgs {
                    dry_run: true,
                    yes: false,
                },
                include: Vec::new(),
            }))),
        },
        config.clone(),
    )
    .await
    .expect("scoped report endpoint dry run");
    assert!(
        report.contains("\"path\": \"api/v1/reports/invoices\""),
        "got: {report}"
    );
    assert!(report.contains("\"method\": \"POST\""), "got: {report}");
    assert!(report.contains("\"date_range\""), "got: {report}");

    let chart = execute_with_config(
        Cli {
            output: OutputFormat::Json,
            command: Some(Commands::Charts(EndpointCommand::Run(EndpointArgs {
                endpoint: Some("charts/totals".to_string()),
                method: Some(HttpMethod::Put),
                payload: {
                    let mut args = empty_resource_payload_args();
                    args.fields.push("currency_id=1".to_string());
                    args
                },
                safety: WriteSafetyArgs {
                    dry_run: true,
                    yes: false,
                },
                include: Vec::new(),
            }))),
        },
        config,
    )
    .await
    .expect("scoped chart endpoint dry run");
    assert!(
        chart.contains("\"path\": \"api/v1/charts/totals\""),
        "got: {chart}"
    );
    assert!(chart.contains("\"method\": \"PUT\""), "got: {chart}");
    assert!(chart.contains("\"currency_id\""), "got: {chart}");
}

#[tokio::test]
async fn endpoint_overrides_reject_write_methods() {
    let config = Config::from_values("http://localhost:1234", "token").expect("config");

    for endpoint in ["clients/client_1/purge", "reports"] {
        let error = execute_with_config(
            Cli {
                output: OutputFormat::Json,
                command: Some(Commands::Reports(EndpointCommand::Run(EndpointArgs {
                    endpoint: Some(endpoint.to_string()),
                    method: Some(HttpMethod::Post),
                    payload: empty_resource_payload_args(),
                    safety: WriteSafetyArgs {
                        dry_run: true,
                        yes: false,
                    },
                    include: Vec::new(),
                }))),
            },
            config.clone(),
        )
        .await
        .expect_err("custom endpoint writes should be rejected");
        assert!(matches!(error, KobanError::InvalidRequest { .. }));
        assert!(error.to_string().contains("read-only"), "got: {error}");
    }
}