ascend-tools-core 1.1.0

SDK for the Ascend Instance web API
Documentation
use std::time::{SystemTime, UNIX_EPOCH};

use ascend_tools::client::AscendClient;
use ascend_tools::config::Config;
use ascend_tools::error::Error;
use ascend_tools::models::FlowRunFilters;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use mockito::{Matcher, Server};

fn test_client(server: &Server) -> AscendClient {
    let key = URL_SAFE_NO_PAD.encode([42u8; 32]);
    let config =
        Config::with_overrides(Some("asc-sa-test"), Some(&key), Some(server.url().as_str()))
            .unwrap();
    AscendClient::new(config).unwrap()
}

fn mock_auth(server: &mut Server, token: &str, expiration: u64, token_expect: usize) {
    server
        .mock("GET", "/api/v1/auth/config")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"cloud_api_domain":"api.cloud.ascend.io"}"#)
        .expect(1)
        .create();

    server
        .mock("POST", "/api/v1/auth/token")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            serde_json::json!({
                "access_token": token,
                "expiration": expiration,
            })
            .to_string(),
        )
        .expect(token_expect)
        .create();
}

#[test]
fn api_error_prefers_detail_field_when_present() {
    let mut server = Server::new();
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    mock_auth(&mut server, "token-a", now + 3600, 1);

    let runtimes = server
        .mock("GET", "/api/v1/runtimes")
        .match_header("authorization", "Bearer token-a")
        .with_status(400)
        .with_header("content-type", "application/json")
        .with_body(r#"{"detail":"bad runtime filter"}"#)
        .expect(1)
        .create();

    let client = test_client(&server);
    let err = client.list_runtimes(Default::default()).unwrap_err();
    runtimes.assert();
    match err {
        Error::ApiError { status, message } => {
            assert_eq!(status, 400);
            assert_eq!(message, "bad runtime filter");
        }
        _ => panic!("unexpected error variant: {err:?}"),
    }
}

#[test]
fn api_error_uses_raw_body_for_non_json_errors() {
    let mut server = Server::new();
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    mock_auth(&mut server, "token-b", now + 3600, 1);

    let runtimes = server
        .mock("GET", "/api/v1/runtimes")
        .match_header("authorization", "Bearer token-b")
        .with_status(400)
        .with_body("bad request body")
        .expect(1)
        .create();

    let client = test_client(&server);
    let err = client.list_runtimes(Default::default()).unwrap_err();
    runtimes.assert();
    match err {
        Error::ApiError { status, message } => {
            assert_eq!(status, 400);
            assert_eq!(message, "bad request body");
        }
        _ => panic!("unexpected error variant: {err:?}"),
    }
}

#[test]
fn encodes_query_values_and_path_segments() {
    let mut server = Server::new();
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    mock_auth(&mut server, "token-c", now + 3600, 1);

    let flow_runs = server
        .mock("GET", "/api/v1/flow-runs")
        .match_header("authorization", "Bearer token-c")
        .match_query(Matcher::AllOf(vec![
            Matcher::UrlEncoded("runtime_uuid".into(), "rt /?#".into()),
            Matcher::UrlEncoded("status".into(), "running & done".into()),
            Matcher::UrlEncoded("flow".into(), "sales/etl".into()),
            Matcher::UrlEncoded("since".into(), "2026-01-01T00:00:00Z".into()),
            Matcher::UrlEncoded("until".into(), "2026-01-02T00:00:00Z".into()),
            Matcher::UrlEncoded("offset".into(), "10".into()),
            Matcher::UrlEncoded("limit".into(), "50".into()),
        ]))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"items":[],"truncated":false}"#)
        .expect(1)
        .create();

    let flow_run = server
        .mock("GET", "/api/v1/flow-runs/fr%2Fwith%20space%23hash")
        .match_header("authorization", "Bearer token-c")
        .match_query(Matcher::UrlEncoded("runtime_uuid".into(), "rt /?#".into()))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            serde_json::json!({
                "name": "fr/with space#hash",
                "flow": "sales/etl",
                "build_uuid": "build-1",
                "runtime_uuid": "rt /?#",
                "status": "running",
                "created_at": "2026-01-01T00:00:00Z",
                "error": null,
            })
            .to_string(),
        )
        .expect(1)
        .create();

    let client = test_client(&server);
    let mut filters = FlowRunFilters::default();
    filters.status = Some("running & done".to_string());
    filters.flow = Some("sales/etl".to_string());
    filters.since = Some("2026-01-01T00:00:00Z".to_string());
    filters.until = Some("2026-01-02T00:00:00Z".to_string());
    filters.offset = Some(10);
    filters.limit = Some(50);
    let result = client.list_flow_runs("rt /?#", filters).unwrap();
    assert!(result.items.is_empty());
    assert!(!result.truncated);

    let run = client.get_flow_run("rt /?#", "fr/with space#hash").unwrap();
    assert_eq!(run.name, "fr/with space#hash");
    flow_runs.assert();
    flow_run.assert();
}

#[test]
fn reuses_cached_token_until_refresh_buffer() {
    let mut server = Server::new();
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    mock_auth(&mut server, "cached-token", now + 3600, 1);

    let runtimes = server
        .mock("GET", "/api/v1/runtimes")
        .match_header("authorization", "Bearer cached-token")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body("[]")
        .expect(2)
        .create();

    let client = test_client(&server);
    let _ = client.list_runtimes(Default::default()).unwrap();
    let _ = client.list_runtimes(Default::default()).unwrap();
    runtimes.assert();
}

#[test]
fn refreshes_token_when_expiration_is_within_buffer() {
    let mut server = Server::new();
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    mock_auth(&mut server, "short-lived", now + 120, 2);

    let runtimes = server
        .mock("GET", "/api/v1/runtimes")
        .match_header("authorization", "Bearer short-lived")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body("[]")
        .expect(2)
        .create();

    let client = test_client(&server);
    let _ = client.list_runtimes(Default::default()).unwrap();
    let _ = client.list_runtimes(Default::default()).unwrap();
    runtimes.assert();
}

#[test]
fn run_flow_returns_typed_error_when_runtime_is_paused_and_resume_is_false() {
    let mut server = Server::new();
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    mock_auth(&mut server, "token-flow-a", now + 3600, 1);

    let runtime = server
        .mock("GET", "/api/v1/runtimes/rt-1")
        .match_header("authorization", "Bearer token-flow-a")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            serde_json::json!({
                "uuid": "rt-1",
                "id": "runtime-1",
                "title": "Runtime",
                "kind": "deployment",
                "project_uuid": "p-1",
                "environment_uuid": "e-1",
                "build_uuid": null,
                "created_at": "2026-01-01T00:00:00Z",
                "updated_at": "2026-01-01T00:00:00Z",
                "health": "running",
                "paused": true
            })
            .to_string(),
        )
        .expect(1)
        .create();

    let resume = server
        .mock("POST", "/api/v1/runtimes/rt-1:resume")
        .expect(0)
        .create();
    let run = server
        .mock("POST", "/api/v1/runtimes/rt-1/flows/sales:run")
        .expect(0)
        .create();

    let client = test_client(&server);
    let err = client.run_flow("rt-1", "sales", None, false).unwrap_err();
    runtime.assert();
    resume.assert();
    run.assert();
    assert!(matches!(err, Error::RuntimePaused));
}

#[test]
fn run_flow_resumes_paused_runtime_when_requested() {
    let mut server = Server::new();
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    mock_auth(&mut server, "token-flow-b", now + 3600, 1);

    let runtime = server
        .mock("GET", "/api/v1/runtimes/rt-1")
        .match_header("authorization", "Bearer token-flow-b")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            serde_json::json!({
                "uuid": "rt-1",
                "id": "runtime-1",
                "title": "Runtime",
                "kind": "deployment",
                "project_uuid": "p-1",
                "environment_uuid": "e-1",
                "build_uuid": null,
                "created_at": "2026-01-01T00:00:00Z",
                "updated_at": "2026-01-01T00:00:00Z",
                "health": "running",
                "paused": true
            })
            .to_string(),
        )
        .expect(1)
        .create();

    let resume = server
        .mock("POST", "/api/v1/runtimes/rt-1:resume")
        .match_header("authorization", "Bearer token-flow-b")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            serde_json::json!({
                "uuid": "rt-1",
                "id": "runtime-1",
                "title": "Runtime",
                "kind": "deployment",
                "project_uuid": "p-1",
                "environment_uuid": "e-1",
                "build_uuid": null,
                "created_at": "2026-01-01T00:00:00Z",
                "updated_at": "2026-01-01T00:00:00Z",
                "health": "running",
                "paused": false
            })
            .to_string(),
        )
        .expect(1)
        .create();

    let run = server
        .mock("POST", "/api/v1/runtimes/rt-1/flows/sales:run")
        .match_header("authorization", "Bearer token-flow-b")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"event_uuid":"event-1","event_type":"flow_run_requested"}"#)
        .expect(1)
        .create();

    let client = test_client(&server);
    let trigger = client.run_flow("rt-1", "sales", None, true).unwrap();
    assert_eq!(trigger.event_uuid, "event-1");
    runtime.assert();
    resume.assert();
    run.assert();
}

#[test]
fn run_flow_returns_typed_error_for_starting_runtime() {
    let mut server = Server::new();
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    mock_auth(&mut server, "token-flow-c", now + 3600, 1);

    let runtime = server
        .mock("GET", "/api/v1/runtimes/rt-1")
        .match_header("authorization", "Bearer token-flow-c")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(
            serde_json::json!({
                "uuid": "rt-1",
                "id": "runtime-1",
                "title": "Runtime",
                "kind": "deployment",
                "project_uuid": "p-1",
                "environment_uuid": "e-1",
                "build_uuid": null,
                "created_at": "2026-01-01T00:00:00Z",
                "updated_at": "2026-01-01T00:00:00Z",
                "health": "starting",
                "paused": false
            })
            .to_string(),
        )
        .expect(1)
        .create();

    let run = server
        .mock("POST", "/api/v1/runtimes/rt-1/flows/sales:run")
        .expect(0)
        .create();

    let client = test_client(&server);
    let err = client.run_flow("rt-1", "sales", None, false).unwrap_err();
    runtime.assert();
    run.assert();
    assert!(matches!(err, Error::RuntimeStarting));
}