koban-cli 0.3.1

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

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

    let cases = [
        (
            "recurring quotes",
            Commands::RecurringQuotes(ResourceCommand::Create(ResourceWriteArgs {
                payload: {
                    let mut args = empty_resource_payload_args();
                    args.client_id = Some("client_1".to_string());
                    args
                },
                safety: WriteSafetyArgs {
                    dry_run: true,
                    yes: false,
                },
                include: Vec::new(),
            })),
            "api/v1/recurring_quotes",
        ),
        (
            "group settings",
            Commands::GroupSettings(ResourceCommand::Create(ResourceWriteArgs {
                payload: {
                    let mut args = empty_resource_payload_args();
                    args.name = Some("Retail clients".to_string());
                    args
                },
                safety: WriteSafetyArgs {
                    dry_run: true,
                    yes: false,
                },
                include: Vec::new(),
            })),
            "api/v1/group_settings",
        ),
        (
            "task schedulers",
            Commands::TaskSchedulers(ResourceCommand::Create(ResourceWriteArgs {
                payload: {
                    let mut args = empty_resource_payload_args();
                    args.name = Some("Weekly sync".to_string());
                    args
                },
                safety: WriteSafetyArgs {
                    dry_run: true,
                    yes: false,
                },
                include: Vec::new(),
            })),
            "api/v1/task_schedulers",
        ),
    ];

    for (label, command, path) in cases {
        let output = execute_with_config(
            Cli {
                output: OutputFormat::Json,
                command: Some(command),
            },
            config.clone(),
        )
        .await
        .unwrap_or_else(|error| panic!("{label} dry run failed: {error}"));
        assert!(output.contains(path), "{label}: {output}");
    }
}

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

    let cases = [
        (
            "quote",
            Commands::Quotes(ResourceCommand::Download(DownloadArgs {
                id: "quote_invitation".to_string(),
                output_file: tempdir.path().join("quote.pdf"),
                force: false,
                include: Vec::new(),
            })),
            "/api/v1/quote/quote_invitation/download",
        ),
        (
            "credit",
            Commands::Credits(ResourceCommand::Download(DownloadArgs {
                id: "credit_invitation".to_string(),
                output_file: tempdir.path().join("credit.pdf"),
                force: false,
                include: Vec::new(),
            })),
            "/api/v1/credit/credit_invitation/download",
        ),
        (
            "recurring invoice",
            Commands::RecurringInvoices(ResourceCommand::Download(DownloadArgs {
                id: "recurring_invitation".to_string(),
                output_file: tempdir.path().join("recurring.pdf"),
                force: false,
                include: Vec::new(),
            })),
            "/api/v1/recurring_invoice/recurring_invitation/download",
        ),
        (
            "purchase order",
            Commands::PurchaseOrders(ResourceCommand::Download(DownloadArgs {
                id: "po_invitation".to_string(),
                output_file: tempdir.path().join("purchase-order.pdf"),
                force: false,
                include: Vec::new(),
            })),
            "/api/v1/purchase_order/po_invitation/download",
        ),
    ];

    for (label, command, path) in cases {
        let mock = server.mock(|when, then| {
            when.method(GET).path(path);
            then.status(200).body("%PDF-1.7");
        });
        execute_with_config(
            Cli {
                output: OutputFormat::Json,
                command: Some(command),
            },
            config.clone(),
        )
        .await
        .unwrap_or_else(|error| panic!("{label} download failed: {error}"));
        mock.assert();
    }
}

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

    let tax_rate_create = execute_with_config(
        Cli {
            output: OutputFormat::Json,
            command: Some(Commands::TaxRates(ResourceCommand::Create(
                ResourceWriteArgs {
                    payload: {
                        let mut args = empty_resource_payload_args();
                        args.name = Some("GST".to_string());
                        args
                    },
                    safety: WriteSafetyArgs {
                        dry_run: true,
                        yes: false,
                    },
                    include: Vec::new(),
                },
            ))),
        },
        config.clone(),
    )
    .await
    .expect_err("tax rate create is not an official route");
    assert!(
        tax_rate_create.to_string().contains("does not support"),
        "got: {tax_rate_create}"
    );

    let documents_upload = execute_with_config(
        Cli {
            output: OutputFormat::Json,
            command: Some(Commands::Documents(ResourceCommand::Upload(UploadArgs {
                id: "document_1".to_string(),
                files: vec![PathBuf::from("/tmp/koban-no-network-needed.txt")],
                safety: WriteSafetyArgs {
                    dry_run: true,
                    yes: false,
                },
                include: Vec::new(),
            }))),
        },
        config,
    )
    .await
    .expect_err("documents upload is not an official route");
    assert!(
        documents_upload.to_string().contains("does not support"),
        "got: {documents_upload}"
    );
}

#[test]
fn route_helpers_cover_special_document_and_bulk_paths() {
    assert_eq!(
        resource_update_path(Resource::PurchaseOrders, "po_1"),
        "api/v1/purchase_order/po_1"
    );
    assert_eq!(
        resource_delete_path(Resource::PurchaseOrders, "po_1"),
        "api/v1/purchase_order/po_1"
    );

    let route = resource_action_route(Resource::Products, "product_1", "archive");
    assert_eq!(route.method, HttpMethod::Post);
    assert_eq!(route.path, "api/v1/products/bulk");
    assert!(route.body);
    assert!(route.is_bulk);

    assert!(resource_download_base_path(Resource::Clients).is_none());
}

#[test]
fn unsupported_capability_errors_name_the_rejected_verb() {
    for (resource, capability, label) in [
        (Resource::Templates, ResourceCapability::List, "list"),
        (Resource::Activities, ResourceCapability::Show, "show"),
        (Resource::TaxRates, ResourceCapability::Template, "template"),
        (
            Resource::Locations,
            ResourceCapability::EditTemplate,
            "edit-template",
        ),
        (Resource::TaxRates, ResourceCapability::Create, "create"),
        (Resource::CompanyUsers, ResourceCapability::Update, "update"),
        (Resource::CompanyUsers, ResourceCapability::Delete, "delete"),
        (Resource::Locations, ResourceCapability::Bulk, "bulk"),
    ] {
        let error =
            require_resource_capability(resource, capability).expect_err("capability rejected");
        assert!(error.to_string().contains(label), "{label}: {error}");
    }
}