koban-cli 0.3.0

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

#[test]
fn upload_file_requires_existing_regular_file() {
    let tempdir = tempfile::tempdir().expect("tempdir");
    let existing = tempdir.path().join("document.txt");
    std::fs::write(&existing, b"upload").expect("seed file");
    ensure_upload_file(&existing).expect("regular files are uploadable");

    let missing = tempdir.path().join("missing.txt");
    let error = ensure_upload_file(&missing).expect_err("missing file");
    assert!(matches!(error, KobanError::File { .. }));

    let error = ensure_upload_file(tempdir.path()).expect_err("directory");
    assert!(matches!(error, KobanError::File { .. }));
}

#[test]
fn invoice_payload_reports_missing_and_malformed_sources() {
    let create_error =
        invoice_payload(empty_payload_args(), true, false).expect_err("create requires payload");
    assert!(matches!(create_error, KobanError::InvalidPayload { .. }));
    assert!(
        create_error
            .to_string()
            .contains("create requires JSON input")
    );

    let update_error =
        invoice_payload(empty_payload_args(), false, false).expect_err("update requires payload");
    assert!(
        update_error
            .to_string()
            .contains("update requires JSON input")
    );

    let trigger_only = invoice_payload(empty_payload_args(), false, true).expect("empty body");
    assert_eq!(trigger_only, serde_json::json!({}));

    let mut invalid_json = empty_payload_args();
    invalid_json.data = Some("{not json".to_string());
    let error = invoice_payload(invalid_json, true, false).expect_err("invalid JSON");
    assert!(error.to_string().contains("JSON could not be parsed"));

    let mut missing_file = empty_payload_args();
    missing_file.data_file = Some(PathBuf::from("/tmp/koban-missing-payload.json"));
    let error = invoice_payload(missing_file, true, false).expect_err("missing file");
    assert!(error.to_string().contains("could not read"));
}

#[test]
fn guided_invoice_payload_handles_all_common_fields_and_line_item_scalars() {
    let mut args = empty_payload_args();
    args.client_id = Some("client_1".to_string());
    args.date = Some("2026-05-28".to_string());
    args.due_date = Some("2026-06-28".to_string());
    args.number = Some("INV-1".to_string());
    args.po_number = Some("PO-1".to_string());
    args.public_notes = Some("public".to_string());
    args.private_notes = Some("private".to_string());
    args.terms = Some("Net 30".to_string());
    args.footer = Some("footer".to_string());
    args.project_id = Some("project_1".to_string());
    args.line_items = vec![
        "product_key=Consulting,quantity=1,cost=99.5,is_amount_discount=false,optional=null"
            .to_string(),
    ];

    let payload = invoice_payload(args, true, false).expect("payload");
    assert_eq!(payload["client_id"], "client_1");
    assert_eq!(payload["due_date"], "2026-06-28");
    assert_eq!(payload["line_items"][0]["quantity"], 1);
    assert_eq!(payload["line_items"][0]["cost"], 99.5);
    assert_eq!(payload["line_items"][0]["is_amount_discount"], false);
    assert!(payload["line_items"][0]["optional"].is_null());
}

#[test]
fn line_item_parser_reports_bad_parts() {
    let error = parse_line_item("not-a-pair").expect_err("missing equals");
    assert!(error.to_string().contains("must use key=value"));

    let error = parse_line_item("=value").expect_err("empty key");
    assert!(error.to_string().contains("empty key"));
}

#[test]
fn invoice_trigger_helpers_build_query_and_confirm_risky_actions() {
    let triggers = InvoiceTriggerArgs {
        send_email: true,
        mark_sent: true,
        paid: true,
        amount_paid: Some("12.50".to_string()),
        cancel: true,
        save_default_footer: true,
        save_default_terms: true,
        retry_e_send: true,
    };
    assert!(triggers.has_any());
    assert!(triggers.requires_confirmation());

    let mut query = Vec::new();
    push_invoice_triggers(&mut query, &triggers);
    assert!(query.contains(&("send_email".to_string(), "true".to_string())));
    assert!(query.contains(&("amount_paid".to_string(), "12.50".to_string())));
    assert!(query.contains(&("retry_e_send".to_string(), "true".to_string())));

    let safety = WriteSafetyArgs {
        dry_run: false,
        yes: false,
    };
    let error = require_confirmation("invoice action", &safety).expect_err("confirmation");
    assert!(matches!(error, KobanError::ConfirmationRequired { .. }));

    require_confirmation(
        "invoice action",
        &WriteSafetyArgs {
            dry_run: true,
            yes: false,
        },
    )
    .expect("dry run allowed");

    let invalid = InvoiceTriggerArgs {
        amount_paid: Some("12.50".to_string()),
        ..empty_trigger_args()
    };
    let error = validate_invoice_triggers(&invalid).expect_err("amount requires paid");
    assert!(error.to_string().contains("--amount-paid requires --paid"));
}

#[test]
fn dry_run_output_includes_body_query_and_files() {
    let files = vec![PathBuf::from("/tmp/document.pdf")];
    let output = render_dry_run(
        "PUT",
        "api/v1/invoices/invoice_1/upload",
        &[("include".to_string(), "documents".to_string())],
        Some(&serde_json::json!({"client_id": "client_1"})),
        Some(&files),
    )
    .expect("dry run");
    assert!(output.contains("\"method\": \"PUT\""), "got: {output}");
    assert!(
        output.contains("\"client_id\": \"client_1\""),
        "got: {output}"
    );
    assert!(output.contains("/tmp/document.pdf"), "got: {output}");
}

#[test]
fn path_segment_validation_rejects_route_changing_actions() {
    validate_path_segment("invoice action", "mark_paid").expect("known safe action");
    validate_path_segment("invoice action", "clone-to-quote").expect("hyphens allowed");

    for bad in [
        "",
        ".",
        "..",
        "../clients",
        "mark/paid",
        "email?include=client",
    ] {
        let error = validate_path_segment("invoice action", bad).expect_err("unsafe action");
        assert!(
            error.to_string().contains("safe single path segment"),
            "got: {error}"
        );
    }
}