diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 511785f3..f6db3731 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -22,3 +22,6 @@ e7fcdb1a8f1e48c564717f1ccd0fa654d182aa73 # Split tests/models_test.rs into per-domain tests/models/ modules (#1173) bb5834b93b5c9d6640c7720991aaef9f750a61ef + +# Split tests/client_test.rs into per-domain tests/client/ modules (#1173) +00b469bdbc23f95f27d97f953e6911d3e523db63 diff --git a/crates/clickhouse-cloud-api/AGENTS.md b/crates/clickhouse-cloud-api/AGENTS.md index 7a0c7602..88c97481 100644 --- a/crates/clickhouse-cloud-api/AGENTS.md +++ b/crates/clickhouse-cloud-api/AGENTS.md @@ -154,8 +154,11 @@ from Rust. HTTP query helper). Used by every integration binary. - `tests/integration_test.rs`, `tests/integration_postgres_test.rs`, `tests/integration_org_test.rs` — cloud-service, Postgres-service, and organization lifecycle. -- `tests/client_test.rs`, `tests/models/`, `tests/model_facade_test.rs`, `tests/run_query_test.rs`, +- `tests/client/`, `tests/models/`, `tests/model_facade_test.rs`, `tests/run_query_test.rs`, `tests/service_query_key_cli_test.rs` — offline client, model, facade and query-path coverage. + `tests/client/` is the single `client_test` binary: `main.rs` holds the shared wiremock helpers and declares one + module per `src/client/` domain that has tests, plus `client.rs` for construction, auth and error handling; add a + client test to the module of the method it calls. `tests/models/` is the single `models_test` binary: `main.rs` declares one module per `src/models/` domain (small domains folded into their parent) plus `cross_domain.rs`; add a model test to its domain's module. - `tests/clickpipes/` — ClickPipes E2E, including external cloud services. Only Postgres CDC (ClickHouse and diff --git a/crates/clickhouse-cloud-api/Cargo.toml b/crates/clickhouse-cloud-api/Cargo.toml index 91d5ce67..cded4a17 100644 --- a/crates/clickhouse-cloud-api/Cargo.toml +++ b/crates/clickhouse-cloud-api/Cargo.toml @@ -52,8 +52,13 @@ uuid = "1" wiremock = "0.6" tempfile = "3.27.0" -# The offline model tests are one binary split into per-domain modules under -# tests/models/; the explicit target keeps the `models_test` name. +# The offline client and model tests are each one binary split into +# per-domain modules under tests/client/ and tests/models/; the explicit +# targets keep the `client_test` and `models_test` names. + +[[test]] +name = "client_test" +path = "tests/client/main.rs" [[test]] name = "models_test" diff --git a/crates/clickhouse-cloud-api/tests/client/activity.rs b/crates/clickhouse-cloud-api/tests/client/activity.rs new file mode 100644 index 00000000..57e6992b --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/activity.rs @@ -0,0 +1,89 @@ +use wiremock::Mock; +use wiremock::matchers::{method, path, query_param}; + +use crate::{ok_json, setup}; + +// =========================================================================== +// Activities +// =========================================================================== + +#[tokio::test] +async fn list_activities() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/activities")) + .respond_with(ok_json(serde_json::json!([ + { + "id": "act-1", + "type": "SERVICE_START", + "actorType": "user", + "organizationId": "org-1", + "serviceId": "svc-1" + } + ]))) + .mount(&s) + .await; + + let resp = c.activity_get_list("org-1", None, None).await.unwrap(); + let activities = resp.result.unwrap(); + assert_eq!(activities.len(), 1); + assert_eq!(activities[0].id.as_deref(), Some("act-1")); +} + +#[tokio::test] +async fn list_activities_with_date_filter() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/activities")) + .and(query_param("from_date", "2024-06-01")) + .and(query_param("to_date", "2024-06-30")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + let resp = c + .activity_get_list("org-1", Some("2024-06-01"), Some("2024-06-30")) + .await + .unwrap(); + assert_eq!(resp.result.unwrap().len(), 0); +} + +#[tokio::test] +async fn get_activity() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/activities/act-1")) + .respond_with(ok_json(serde_json::json!({ + "id": "act-1", + "type": "SERVICE_STOP", + "actorType": "api" + }))) + .mount(&s) + .await; + + let resp = c.activity_get("org-1", "act-1").await.unwrap(); + let activity = resp.result.unwrap(); + assert_eq!(activity.id.as_deref(), Some("act-1")); +} + +#[tokio::test] +async fn activity_list_with_only_from_date() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/activities")) + .and(query_param("from_date", "2024-06-01")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + let resp = c + .activity_get_list("org-1", Some("2024-06-01"), None) + .await + .unwrap(); + let activities = resp.result.unwrap(); + assert!(activities.is_empty()); +} diff --git a/crates/clickhouse-cloud-api/tests/client/api_keys.rs b/crates/clickhouse-cloud-api/tests/client/api_keys.rs new file mode 100644 index 00000000..f193e88a --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/api_keys.rs @@ -0,0 +1,243 @@ +use chrono::Utc; +use clickhouse_cloud_api::{Client, models::*}; +use wiremock::matchers::{basic_auth, body_json, body_partial_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::{ok_empty, ok_json, setup}; + +// =========================================================================== +// API Keys +// =========================================================================== + +#[tokio::test] +async fn list_api_keys() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/keys")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": [ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "Production Key", + "state": "enabled" + } + ] + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let resp = client + .openapi_key_get_list("org-1", None, None) + .await + .unwrap(); + let keys = resp.result.unwrap(); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].name.as_deref(), Some("Production Key")); +} + +#[tokio::test] +async fn list_api_keys_pagination_encodes_parameters_and_preserves_envelope() { + let (server, client) = setup().await; + let cursor = "opaque +/=&?雪"; + Mock::given(method("GET")) + .and(path("/v1/organizations/org/keys")) + .and(basic_auth("key", "secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200.0, "requestId": "req-page", "result": [], + "limit": 25, "totalCount": 100, "nextCursor": cursor + }))) + .expect(4) + .mount(&server) + .await; + for (limit, cursor_arg) in [ + (None, None), + (Some(25), None), + (None, Some(cursor)), + (Some(25), Some(cursor)), + ] { + let response = client + .openapi_key_get_list("org", limit, cursor_arg) + .await + .unwrap(); + assert_eq!(response.status, Some(200)); + assert_eq!(response.request_id.as_deref(), Some("req-page")); + assert_eq!(response.result, Some(vec![])); + assert_eq!(response.error, None); + assert_eq!(response.limit, Some(25)); + assert_eq!(response.total_count, Some(100)); + assert_eq!(response.next_cursor.as_deref(), Some(cursor)); + } + let requests = server.received_requests().await.unwrap(); + let queries: Vec> = requests + .iter() + .map(|request| request.url.query_pairs().into_owned().collect()) + .collect(); + assert_eq!( + queries, + vec![ + vec![], + vec![("limit".into(), "25".into())], + vec![("cursor".into(), cursor.into())], + vec![ + ("limit".into(), "25".into()), + ("cursor".into(), cursor.into()) + ], + ] + ); +} + +#[tokio::test] +async fn list_api_keys_preserves_api_errors() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org/keys")) + .and(basic_auth("key", "secret")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "status": 403.0, "error": "forbidden", "requestId": "req-denied" + }))) + .expect(1) + .mount(&server) + .await; + let error = client + .openapi_key_get_list("org", Some(25), Some("cursor")) + .await + .unwrap_err(); + assert!( + matches!(error, clickhouse_cloud_api::Error::Api { status: 403, message } if message == "forbidden") + ); +} + +#[tokio::test] +async fn create_api_key() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/keys")) + .and(body_partial_json(serde_json::json!({"name": "New Key"}))) + .respond_with(ok_json(serde_json::json!({ + "key": { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "New Key", + "state": "enabled" + }, + "keyId": "key-id-abc", + "keySecret": "key-secret-xyz" + }))) + .mount(&s) + .await; + + let body = ApiKeyPostRequest { + name: "New Key".to_string(), + ..Default::default() + }; + let resp = c.openapi_key_create("org-1", &body).await.unwrap(); + let result = resp.result.unwrap(); + assert_eq!(result.key_id.as_deref(), Some("key-id-abc")); + assert_eq!(result.key_secret.as_deref(), Some("key-secret-xyz")); + assert_eq!( + result.key.as_ref().and_then(|key| key.name.as_deref()), + Some("New Key") + ); +} + +#[tokio::test] +async fn get_api_key() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/keys/key-1")) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "My Key", + "state": "enabled", + "keySuffix": "abc" + }))) + .mount(&s) + .await; + + let resp = c.openapi_key_get("org-1", "key-1").await.unwrap(); + let key = resp.result.unwrap(); + assert_eq!(key.name.as_deref(), Some("My Key")); + assert_eq!(key.state, Some(ApiKeyState::Enabled)); + assert_eq!(key.key_suffix.as_deref(), Some("abc")); +} + +#[tokio::test] +async fn update_api_key() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/keys/key-1")) + .and(body_partial_json( + serde_json::json!({"name": "Renamed Key"}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "Renamed Key", + "state": "enabled" + }))) + .mount(&s) + .await; + + let body = ApiKeyPatchRequest { + name: Some("Renamed Key".to_string()), + ..Default::default() + }; + let resp = c.openapi_key_update("org-1", "key-1", &body).await.unwrap(); + let key = resp.result.unwrap(); + assert_eq!(key.name.as_deref(), Some("Renamed Key")); +} + +#[tokio::test] +async fn delete_api_key() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-1/keys/key-1")) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c.openapi_key_delete("org-1", "key-1").await.unwrap(); + assert_eq!(resp.status, Some(200)); +} + +#[tokio::test] +async fn update_api_key_sends_omitted_timestamp_and_null_expiry() { + let timestamp = chrono::DateTime::parse_from_rfc3339("2030-01-02T03:04:05Z") + .unwrap() + .with_timezone(&Utc); + for (expire_at, wire) in [ + (None, serde_json::json!({"name": "retained"})), + ( + Some(Some(timestamp)), + serde_json::json!({"name": "retained", "expireAt": "2030-01-02T03:04:05Z"}), + ), + ( + Some(None), + serde_json::json!({"name": "retained", "expireAt": null}), + ), + ] { + let (server, client) = setup().await; + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/keys/key-1")) + .and(body_json(wire)) + .respond_with(ok_json(serde_json::json!({"name": "retained"}))) + .expect(1) + .mount(&server) + .await; + let request = ApiKeyPatchRequest { + name: Some("retained".into()), + expire_at, + ..Default::default() + }; + let response = client + .openapi_key_update("org-1", "key-1", &request) + .await + .unwrap(); + assert_eq!(response.result.unwrap().name.as_deref(), Some("retained")); + } +} diff --git a/crates/clickhouse-cloud-api/tests/client/backups.rs b/crates/clickhouse-cloud-api/tests/client/backups.rs new file mode 100644 index 00000000..f27691b0 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/backups.rs @@ -0,0 +1,607 @@ +use clickhouse_cloud_api::{Client, models::*}; +use wiremock::matchers::{basic_auth, body_json, body_partial_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::{ok_empty, ok_json, setup}; + +#[tokio::test] +async fn list_snapshots_pagination_encodes_parameters_and_preserves_envelope() { + let (server, client) = setup().await; + let cursor = "opaque +/=&?雪"; + Mock::given(method("GET")) + .and(path("/v1/organizations/org/services/svc/snapshots")) + .and(basic_auth("key", "secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200.0, "requestId": "req-page", "result": [], + "limit": 25, "totalCount": 100, "nextCursor": cursor + }))) + .expect(4) + .mount(&server) + .await; + for (limit, cursor_arg) in [ + (None, None), + (Some(25), None), + (None, Some(cursor)), + (Some(25), Some(cursor)), + ] { + let response = client + .snapshot_get_list("org", "svc", limit, cursor_arg) + .await + .unwrap(); + assert_eq!(response.status, Some(200)); + assert_eq!(response.request_id.as_deref(), Some("req-page")); + assert_eq!(response.result, Some(vec![])); + assert_eq!(response.error, None); + assert_eq!(response.limit, Some(25)); + assert_eq!(response.total_count, Some(100)); + assert_eq!(response.next_cursor.as_deref(), Some(cursor)); + } + let requests = server.received_requests().await.unwrap(); + let queries: Vec> = requests + .iter() + .map(|request| request.url.query_pairs().into_owned().collect()) + .collect(); + assert_eq!( + queries, + vec![ + vec![], + vec![("limit".into(), "25".into())], + vec![("cursor".into(), cursor.into())], + vec![ + ("limit".into(), "25".into()), + ("cursor".into(), cursor.into()) + ], + ] + ); +} + +// =========================================================================== +// Backups & Backup Configuration +// =========================================================================== + +#[tokio::test] +async fn get_snapshot_configuration() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", + )) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!({ + "enabled": true, "gap": 30, "timeFrame": 1440 + }))) + .expect(1) + .mount(&server) + .await; + + let configuration = client + .snapshot_configuration_get("org-1", "svc-1") + .await + .unwrap() + .result + .unwrap(); + assert_eq!(configuration.enabled, Some(true)); + assert_eq!(configuration.gap, Some(30.0)); + assert_eq!(configuration.time_frame, Some(1440.0)); +} + +#[tokio::test] +async fn update_snapshot_configuration_sends_supported_presets() { + for (gap, time_frame) in [(30.0, 1440.0), (60.0, 2880.0)] { + let (server, client) = setup().await; + let wire = serde_json::json!({ + "enabled": true, "gap": gap, "timeFrame": time_frame + }); + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", + )) + .and(basic_auth("key", "secret")) + .and(body_json(wire.clone())) + .respond_with(ok_json(wire)) + .expect(1) + .mount(&server) + .await; + + let configuration = client + .snapshot_configuration_update( + "org-1", + "svc-1", + &SnapshotConfigurationPatchRequest { + enabled: Some(true), + gap: Some(gap), + time_frame: Some(time_frame), + }, + ) + .await + .unwrap() + .result + .unwrap(); + assert_eq!(configuration.enabled, Some(true)); + assert_eq!(configuration.gap, Some(gap)); + assert_eq!(configuration.time_frame, Some(time_frame)); + } +} + +#[tokio::test] +async fn update_snapshot_configuration_omits_unchanged_fields() { + let (server, client) = setup().await; + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", + )) + .and(basic_auth("key", "secret")) + .and(body_json(serde_json::json!({"enabled": false}))) + .respond_with(ok_json(serde_json::json!({"enabled": false}))) + .expect(1) + .mount(&server) + .await; + + let configuration = client + .snapshot_configuration_update( + "org-1", + "svc-1", + &SnapshotConfigurationPatchRequest { + enabled: Some(false), + ..Default::default() + }, + ) + .await + .unwrap() + .result + .unwrap(); + assert_eq!(configuration.enabled, Some(false)); + assert_eq!(configuration.gap, None); + assert_eq!(configuration.time_frame, None); +} + +#[tokio::test] +async fn update_snapshot_configuration_leaves_validation_to_the_server() { + for (request, wire, message) in [ + ( + SnapshotConfigurationPatchRequest::default(), + serde_json::json!({}), + "Provide at least one field", + ), + ( + SnapshotConfigurationPatchRequest { + enabled: Some(true), + gap: Some(0.0), + time_frame: Some(0.0), + }, + serde_json::json!({"enabled": true, "gap": 0.0, "timeFrame": 0.0}), + "Unsupported snapshot cadence", + ), + ] { + let (server, client) = setup().await; + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", + )) + .and(basic_auth("key", "secret")) + .and(body_json(wire)) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "status": 400, "error": message + }))) + .expect(1) + .mount(&server) + .await; + + let error = client + .snapshot_configuration_update("org-1", "svc-1", &request) + .await + .unwrap_err(); + assert!( + matches!(error, clickhouse_cloud_api::Error::Api { status: 400, message: actual } if actual == message) + ); + } +} + +#[tokio::test] +async fn snapshot_configuration_methods_preserve_api_errors() { + for (status, response, message) in [ + ( + 403, + ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "status": 403, "error": "Access denied" + })), + "Access denied", + ), + ( + 500, + ResponseTemplate::new(500).set_body_string("Snapshot configuration unavailable"), + "Snapshot configuration unavailable", + ), + ] { + let (server, client) = setup().await; + Mock::given(path( + "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", + )) + .respond_with(response) + .expect(2) + .mount(&server) + .await; + + let errors = [ + client + .snapshot_configuration_get("org-1", "svc-1") + .await + .unwrap_err(), + client + .snapshot_configuration_update( + "org-1", + "svc-1", + &SnapshotConfigurationPatchRequest { + enabled: Some(false), + ..Default::default() + }, + ) + .await + .unwrap_err(), + ]; + for error in errors { + assert!( + matches!(error, clickhouse_cloud_api::Error::Api { status: actual_status, message: actual_message } if actual_status == status && actual_message == message) + ); + } + } +} + +#[tokio::test] +async fn snapshot_configuration_methods_reject_malformed_success_payloads() { + let (server, client) = setup().await; + Mock::given(path( + "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", + )) + .respond_with(ResponseTemplate::new(200).set_body_string("not JSON")) + .expect(2) + .mount(&server) + .await; + + assert!(matches!( + client.snapshot_configuration_get("org-1", "svc-1").await, + Err(clickhouse_cloud_api::Error::Json(_)) + )); + assert!(matches!( + client + .snapshot_configuration_update( + "org-1", + "svc-1", + &SnapshotConfigurationPatchRequest { + enabled: Some(false), + ..Default::default() + }, + ) + .await, + Err(clickhouse_cloud_api::Error::Json(_)) + )); +} + +#[tokio::test] +async fn list_snapshots() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/snapshots")) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!([ + {"status": "throttled", "type": "full", "durationInSeconds": 1.5}, + {"status": "done", "type": "full", "sizeInBytes": 2048} + ]))) + .expect(1) + .mount(&server) + .await; + + let snapshots = client + .snapshot_get_list("org-1", "svc-1", None, None) + .await + .unwrap() + .result + .unwrap(); + assert_eq!(snapshots.len(), 2); + assert_eq!(snapshots[0].status, Some(SnapshotStatus::Throttled)); + assert_eq!(snapshots[0].r#type, Some(SnapshotType::Full)); + assert_eq!(snapshots[0].duration_in_seconds, Some(1.5)); + assert_eq!(snapshots[1].status, Some(SnapshotStatus::Done)); +} + +#[tokio::test] +async fn get_snapshot() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/snapshots/snapshot-1", + )) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "serviceId": "svc-1", + "status": "done", + "type": "full", + "sizeInBytes": 2048, + "bucket": {"bucketProvider": "AWS", "bucketPath": "s3://backups"} + }))) + .expect(1) + .mount(&server) + .await; + + let snapshot = client + .snapshot_get("org-1", "svc-1", "snapshot-1") + .await + .unwrap() + .result + .unwrap(); + assert_eq!( + snapshot.id.unwrap().to_string(), + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ); + assert_eq!(snapshot.service_id.as_deref(), Some("svc-1")); + assert_eq!(snapshot.size_in_bytes, Some(2048.0)); + assert_eq!(snapshot.bucket.unwrap()["bucketPath"], "s3://backups"); +} + +#[tokio::test] +async fn snapshot_methods_preserve_api_errors() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/snapshots")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "status": 403, "error": "Access denied" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/snapshots/missing", + )) + .respond_with(ResponseTemplate::new(500).set_body_string("Snapshot lookup failed")) + .expect(1) + .mount(&server) + .await; + + let list_error = client + .snapshot_get_list("org-1", "svc-1", None, None) + .await + .unwrap_err(); + assert!( + matches!(list_error, clickhouse_cloud_api::Error::Api { status: 403, message } if message == "Access denied") + ); + let get_error = client + .snapshot_get("org-1", "svc-1", "missing") + .await + .unwrap_err(); + assert!( + matches!(get_error, clickhouse_cloud_api::Error::Api { status: 500, message } if message == "Snapshot lookup failed") + ); +} + +#[tokio::test] +async fn snapshot_methods_reject_malformed_success_payloads() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_string("not JSON")) + .expect(2) + .mount(&server) + .await; + assert!(matches!( + client.snapshot_get_list("org-1", "svc-1", None, None).await, + Err(clickhouse_cloud_api::Error::Json(_)) + )); + assert!(matches!( + client.snapshot_get("org-1", "svc-1", "snapshot-1").await, + Err(clickhouse_cloud_api::Error::Json(_)) + )); +} + +#[tokio::test] +async fn list_backups() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/backups")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": [ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "status": "done", + "serviceId": "svc-1", + "startedAt": "2024-06-01T00:00:00Z", + "finishedAt": "2024-06-01T00:05:00Z", + "sizeInBytes": 1024, + "durationInSeconds": 300, + "type": "full" + } + ] + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let resp = client.backup_get_list("org-1", "svc-1").await.unwrap(); + let backups = resp.result.unwrap(); + assert_eq!(backups.len(), 1); + assert_eq!(backups[0].status, Some(BackupStatus::Done)); + assert_eq!(backups[0].r#type, Some(BackupType::Full)); +} + +#[tokio::test] +async fn get_single_backup() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/backups/bak-1")) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "status": "done", + "sizeInBytes": 2048, + "type": "full" + }))) + .mount(&s) + .await; + + let resp = c.backup_get("org-1", "svc-1", "bak-1").await.unwrap(); + let backup = resp.result.unwrap(); + assert_eq!(backup.status, Some(BackupStatus::Done)); + assert_eq!(backup.size_in_bytes, Some(2048.0)); +} + +#[tokio::test] +async fn get_backup_configuration() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/backupConfiguration", + )) + .respond_with(ok_json(serde_json::json!({ + "backupPeriodInHours": 24, + "backupRetentionPeriodInHours": 168, + "backupStartTime": "02:00" + }))) + .mount(&s) + .await; + + let resp = c.backup_configuration_get("org-1", "svc-1").await.unwrap(); + let config = resp.result.unwrap(); + assert_eq!(config.backup_period_in_hours, Some(24.0)); + assert_eq!(config.backup_start_time.as_deref(), Some("02:00")); +} + +#[tokio::test] +async fn update_backup_configuration() { + let mock_server = MockServer::start().await; + + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/backupConfiguration", + )) + .and(body_partial_json(serde_json::json!({"backupPeriodInHours": 12.0, "backupRetentionPeriodInHours": 336.0, "backupStartTime": "03:00"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": { + "backupPeriodInHours": 12, + "backupRetentionPeriodInHours": 336, + "backupStartTime": "03:00" + } + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let body = BackupConfigurationPatchRequest { + backup_period_in_hours: Some(12.0), + backup_retention_period_in_hours: Some(336.0), + backup_start_time: Some(Some("03:00".to_string())), + }; + let resp = client + .backup_configuration_update("org-1", "svc-1", &body) + .await + .unwrap(); + let config = resp.result.unwrap(); + assert_eq!(config.backup_period_in_hours, Some(12.0)); +} + +// =========================================================================== +// Backup Buckets +// =========================================================================== + +#[tokio::test] +async fn get_backup_bucket() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) + .respond_with(ok_json(serde_json::json!({ + "bucketPath": "s3://my-backup-bucket/prefix", + "bucketProvider": "AWS", + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "iamRoleArn": "arn:aws:iam::123:role/backup-role", + "iamRoleExternalId": "trust-policy-id" + }))) + .mount(&s) + .await; + + let resp = c.backup_bucket_get("org-1", "svc-1").await.unwrap(); + let Some(BackupBucket::AwsBackupBucket(bucket)) = resp.result else { + panic!("expected AWS bucket"); + }; + assert_eq!( + bucket.iam_role_external_id.as_deref(), + Some("trust-policy-id") + ); +} + +#[tokio::test] +async fn create_backup_bucket() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) + .and(body_partial_json( + serde_json::json!({"bucketPath": "s3://new-bucket", "iamRoleExternalId": "trust-policy-id"}), + )) + .respond_with(ok_json(serde_json::json!({ + "bucketPath": "s3://new-bucket", + "bucketProvider": "AWS", + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }))) + .mount(&s) + .await; + + let body = + BackupBucketPostRequest::AwsBackupBucketPostRequestV1(AwsBackupBucketPostRequestV1 { + bucket_path: "s3://new-bucket".to_string(), + iam_role_external_id: "trust-policy-id".to_string(), + ..Default::default() + }); + let resp = c + .backup_bucket_create("org-1", "svc-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn update_backup_bucket() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) + .and(body_partial_json( + serde_json::json!({"bucketPath": "s3://updated-bucket", "iamRoleExternalId": null}), + )) + .respond_with(ok_json(serde_json::json!({ + "bucketPath": "s3://updated-bucket", + "bucketProvider": "AWS", + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }))) + .mount(&s) + .await; + + let body = + BackupBucketPatchRequest::AwsBackupBucketPatchRequestV1(AwsBackupBucketPatchRequestV1 { + bucket_path: "s3://updated-bucket".to_string(), + iam_role_external_id: Some(None), + ..Default::default() + }); + let resp = c + .backup_bucket_update("org-1", "svc-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn delete_backup_bucket() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c.backup_bucket_delete("org-1", "svc-1").await.unwrap(); + assert_eq!(resp.status, Some(200)); +} diff --git a/crates/clickhouse-cloud-api/tests/client/byoc.rs b/crates/clickhouse-cloud-api/tests/client/byoc.rs new file mode 100644 index 00000000..534d8809 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/byoc.rs @@ -0,0 +1,465 @@ +use clickhouse_cloud_api::models::*; +use wiremock::matchers::{basic_auth, body_json, body_partial_json, method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::{ok_empty, ok_json, setup}; + +// =========================================================================== +// BYOC Infrastructure +// =========================================================================== + +#[tokio::test] +async fn create_byoc_infrastructure() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/byocInfrastructure")) + .and(body_partial_json(serde_json::json!({ + "accountId": "123456789012", + "availabilityZoneSuffixes": ["a", "b"], + "displayName": "My BYOC" + }))) + .respond_with(ok_json(serde_json::json!({ + "id": "byoc-1", + "cloudProvider": "aws", + "displayName": "My BYOC" + }))) + .mount(&s) + .await; + + let body = ByocInfrastructurePostRequest { + account_id: "123456789012".to_string(), + availability_zone_suffixes: Some(vec![ + ByocAvailabilityZoneSuffix::A, + ByocAvailabilityZoneSuffix::B, + ]), + display_name: Some("My BYOC".to_string()), + ..Default::default() + }; + let resp = c + .organization_byoc_infrastructure_create("org-1", &body) + .await + .unwrap(); + let config = resp.result.unwrap(); + assert_eq!(config.display_name.as_deref(), Some("My BYOC")); +} + +#[tokio::test] +async fn create_byoc_infrastructure_sends_byo_vpc_fields_and_tags() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/byocInfrastructure")) + .and(body_json(serde_json::json!({ + "accountId": "123456789012", + "regionId": "us-east-1", + "externalId": "ch-ext", + "vpcId": "vpc-1", + "privateSubnetIds": ["subnet-a", "subnet-b"], + "publicSubnetIds": ["subnet-pub"], + "tags": {"team": "data", "cost-center": "42"} + }))) + .respond_with(ok_json(serde_json::json!({"id": "byoc-1"}))) + .expect(1) + .mount(&s) + .await; + + let body = ByocInfrastructurePostRequest { + account_id: "123456789012".to_string(), + region_id: ByocInfrastructurePostRequestRegionid::Us_east_1, + external_id: Some("ch-ext".to_string()), + vpc_id: Some("vpc-1".to_string()), + private_subnet_ids: Some(vec!["subnet-a".to_string(), "subnet-b".to_string()]), + public_subnet_ids: Some(vec!["subnet-pub".to_string()]), + tags: Some(ByocInfrastructureTags::from([ + ("team".to_string(), "data".to_string()), + ("cost-center".to_string(), "42".to_string()), + ])), + ..Default::default() + }; + let resp = c + .organization_byoc_infrastructure_create("org-1", &body) + .await + .unwrap(); + assert_eq!(resp.result.unwrap().id.as_deref(), Some("byoc-1")); +} + +#[tokio::test] +async fn validate_byoc_infrastructure() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/byocInfrastructure/validate")) + .and(body_json(serde_json::json!({ + "accountId": "123456789012", + "regionId": "eu-west-1", + "availabilityZoneSuffixes": ["a", "b", "c"], + "vpcCidrRange": "10.0.0.0/16", + "externalId": "ch-ext" + }))) + .respond_with(ok_json(serde_json::json!({ + "cloudProvider": "aws", + "allPassed": false, + "anyPassed": true, + "supported": true, + "checks": [ + { + "name": "Create EKS cluster", + "action": "eks:CreateCluster", + "allowed": true, + "group": "base" + }, + { + "name": "Create VPC", + "action": "ec2:CreateVpc", + "allowed": false, + "reason": "implicitDeny", + "group": "vpc-write" + } + ] + }))) + .expect(1) + .mount(&s) + .await; + + let body = ByocInfrastructureValidatePostRequest { + account_id: "123456789012".to_string(), + region_id: ByocInfrastructureValidatePostRequestRegionid::Eu_west_1, + availability_zone_suffixes: Some(vec![ + ByocAvailabilityZoneSuffix::A, + ByocAvailabilityZoneSuffix::B, + ByocAvailabilityZoneSuffix::C, + ]), + vpc_cidr_range: Some("10.0.0.0/16".to_string()), + external_id: Some("ch-ext".to_string()), + ..Default::default() + }; + let resp = c + .organization_byoc_infrastructure_validate("org-1", &body) + .await + .unwrap(); + let validation = resp.result.unwrap(); + assert_eq!( + validation.cloud_provider, + Some(ByocInfrastructureValidationCloudprovider::Aws) + ); + assert_eq!(validation.all_passed, Some(false)); + assert_eq!(validation.any_passed, Some(true)); + assert_eq!(validation.supported, Some(true)); + let checks = validation.checks.unwrap(); + assert_eq!(checks.len(), 2); + assert_eq!(checks[0].action.as_deref(), Some("eks:CreateCluster")); + assert_eq!(checks[0].allowed, Some(true)); + assert_eq!(checks[0].reason, None); + assert_eq!(checks[1].allowed, Some(false)); + assert_eq!(checks[1].reason.as_deref(), Some("implicitDeny")); + assert_eq!(checks[1].group.as_deref(), Some("vpc-write")); +} + +#[tokio::test] +async fn get_byoc_infrastructure() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/byocInfrastructure/byoc-1")) + .respond_with(ok_json(serde_json::json!({ + "id": "byoc-1", + "state": "infra-provisioning", + "accountId": "123456789012", + "regionId": "us-central1", + "cloudProvider": "gcp", + "displayName": "My BYOC", + "enablePrivateLink": true, + "enablePrivateLoadBalancer": false, + "enablePublicLoadBalancer": true, + "vpcAvailabilityZoneList": ["us-central1-a", "us-central1-b"], + "isByoVpc": true, + "byoVpcId": "my-network", + "byoVpcPrivateSubnetIds": ["my-subnet"], + "byoVpcPodCidrRangeNames": ["pods"], + "byoVpcSharedVpcHostProjectId": "host-project", + "gcpPscSubnetId": "psc-subnet" + }))) + .expect(1) + .mount(&s) + .await; + + let resp = c + .organization_byoc_infrastructure_get("org-1", "byoc-1") + .await + .unwrap(); + let details = resp.result.unwrap(); + assert_eq!(details.id.as_deref(), Some("byoc-1")); + assert_eq!( + details.state, + Some(ByocInfrastructureDetailsState::Infra_provisioning) + ); + assert_eq!( + details.region_id, + Some(ByocInfrastructureDetailsRegionid::Us_central1) + ); + assert_eq!( + details.cloud_provider, + Some(ByocInfrastructureDetailsCloudprovider::Gcp) + ); + assert_eq!(details.enable_private_link, Some(true)); + assert_eq!(details.enable_private_load_balancer, Some(false)); + assert_eq!(details.is_byo_vpc, Some(true)); + assert_eq!(details.byo_vpc_id.as_deref(), Some("my-network")); + assert_eq!( + details.byo_vpc_private_subnet_ids, + Some(vec!["my-subnet".to_string()]) + ); + assert_eq!( + details.byo_vpc_shared_vpc_host_project_id.as_deref(), + Some("host-project") + ); + assert_eq!(details.gcp_psc_subnet_id.as_deref(), Some("psc-subnet")); + assert_eq!(details.vpc_cidr_range, None); +} + +#[tokio::test] +async fn get_byoc_infrastructure_progress() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/byocInfrastructure/byoc-1/progress", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "byoc-1", + "status": "in_progress", + "updatedAt": "2026-09-30T12:00:00Z", + "stages": [ + { + "name": "network", + "status": "ready", + "subStages": [{"name": "vpc", "status": "ready"}] + }, + {"name": "cluster", "status": "failed", "message": "quota exceeded"} + ] + }))) + .expect(1) + .mount(&s) + .await; + + let resp = c + .organization_byoc_infrastructure_progress_get("org-1", "byoc-1") + .await + .unwrap(); + let progress = resp.result.unwrap(); + assert_eq!(progress.id.as_deref(), Some("byoc-1")); + assert_eq!( + progress.status, + Some(ByocInfrastructureProgressStatus::InProgress) + ); + assert_eq!( + progress.updated_at.map(|t| t.to_rfc3339()), + Some("2026-09-30T12:00:00+00:00".to_string()) + ); + let stages = progress.stages.unwrap(); + assert_eq!(stages.len(), 2); + let sub = stages[0].sub_stages.as_ref().unwrap(); + assert_eq!(sub[0].name.as_deref(), Some("vpc")); + assert_eq!( + stages[1].status, + Some(ByocInfrastructureProgressStageStatus::Failed) + ); + assert_eq!(stages[1].message.as_deref(), Some("quota exceeded")); +} + +#[tokio::test] +async fn byoc_infrastructure_get_surfaces_api_errors() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/byocInfrastructure/missing/progress", + )) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "status": 404, + "error": "BYOC infrastructure not found" + }))) + .mount(&s) + .await; + + let err = c + .organization_byoc_infrastructure_progress_get("org-1", "missing") + .await + .unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 404); + assert_eq!(message, "BYOC infrastructure not found"); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[tokio::test] +async fn update_byoc_infrastructure() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/byocInfrastructure/byoc-1")) + .and(body_partial_json( + serde_json::json!({"displayName": "Renamed BYOC"}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "byoc-1", + "displayName": "Renamed BYOC" + }))) + .mount(&s) + .await; + + let body = ByocInfrastructurePatchRequest { + display_name: Some("Renamed BYOC".to_string()), + ..Default::default() + }; + let resp = c + .organization_byoc_infrastructure_update("org-1", "byoc-1", &body) + .await + .unwrap(); + let config = resp.result.unwrap(); + assert_eq!(config.display_name.as_deref(), Some("Renamed BYOC")); +} + +#[tokio::test] +async fn update_byoc_infrastructure_sends_connectivity_and_tags() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/byocInfrastructure/byoc-1")) + .and(basic_auth("key", "secret")) + .and(body_json(serde_json::json!({ + "enablePrivateLink": true, + "enablePrivateLoadBalancer": false, + "enablePublicLoadBalancer": true, + "gcpPscSubnetId": "psc-subnet", + "tags": {"team": "data"} + }))) + .respond_with(ok_json(serde_json::json!({"id": "byoc-1"}))) + .expect(1) + .mount(&s) + .await; + + let body = ByocInfrastructurePatchRequest { + display_name: None, + enable_private_link: Some(true), + enable_private_load_balancer: Some(false), + enable_public_load_balancer: Some(true), + gcp_psc_subnet_id: Some("psc-subnet".to_string()), + tags: Some(ByocInfrastructureTags::from([( + "team".to_string(), + "data".to_string(), + )])), + }; + let resp = c + .organization_byoc_infrastructure_update("org-1", "byoc-1", &body) + .await + .unwrap(); + assert_eq!(resp.result.unwrap().id.as_deref(), Some("byoc-1")); +} + +#[tokio::test] +async fn get_byoc_infrastructure_private_endpoint_config() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/byocInfrastructure/byoc-1/privateEndpointConfig", + )) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!({ + "endpointName": "com.amazonaws.vpce.us-east-1.vpce-svc-123", + "privateDnsHostname": "byoc-1.private.clickhouse.cloud" + }))) + .expect(1) + .mount(&s) + .await; + + let resp = c + .organization_byoc_infrastructure_private_endpoint_config_get("org-1", "byoc-1") + .await + .unwrap(); + let config = resp.result.unwrap(); + assert_eq!( + config.endpoint_name.as_deref(), + Some("com.amazonaws.vpce.us-east-1.vpce-svc-123") + ); + assert_eq!( + config.private_dns_hostname.as_deref(), + Some("byoc-1.private.clickhouse.cloud") + ); +} + +#[tokio::test] +async fn get_byoc_infrastructure_tags() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/byocInfrastructure/byoc-1/tags", + )) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!({ + "tags": {"team": "data", "env": "prod"} + }))) + .expect(1) + .mount(&s) + .await; + + let resp = c + .organization_byoc_infrastructure_tags_get("org-1", "byoc-1") + .await + .unwrap(); + let tags = resp.result.unwrap().tags.unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!(tags.get("team").map(String::as_str), Some("data")); + assert_eq!(tags.get("env").map(String::as_str), Some("prod")); +} + +#[tokio::test] +async fn byoc_infrastructure_tags_get_surfaces_api_errors() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/byocInfrastructure/missing/tags", + )) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "status": 404, + "error": "BYOC infrastructure not found" + }))) + .mount(&s) + .await; + + let err = c + .organization_byoc_infrastructure_tags_get("org-1", "missing") + .await + .unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 404); + assert_eq!(message, "BYOC infrastructure not found"); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[tokio::test] +async fn delete_byoc_infrastructure() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-1/byocInfrastructure/byoc-1")) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c + .organization_byoc_infrastructure_delete("org-1", "byoc-1") + .await + .unwrap(); + assert_eq!(resp.status, Some(200)); +} diff --git a/crates/clickhouse-cloud-api/tests/client/clickpipes.rs b/crates/clickhouse-cloud-api/tests/client/clickpipes.rs new file mode 100644 index 00000000..a18ac986 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/clickpipes.rs @@ -0,0 +1,634 @@ +use clickhouse_cloud_api::models::*; +use wiremock::Mock; +use wiremock::matchers::{basic_auth, body_partial_json, method, path}; + +use crate::{ok_empty, ok_json, setup}; + +// =========================================================================== +// ClickPipes +// =========================================================================== + +#[tokio::test] +async fn list_click_pipes() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/clickpipes")) + .respond_with(ok_json(serde_json::json!([ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "kafka-pipe", + "state": "Running" + } + ]))) + .mount(&s) + .await; + + let resp = c.click_pipe_get_list("org-1", "svc-1").await.unwrap(); + let pipes = resp.result.unwrap(); + assert_eq!(pipes.len(), 1); + assert_eq!(pipes[0].name.as_deref(), Some("kafka-pipe")); +} + +/// Mirror the shape the live API actually returns for a Kafka pipe — including +/// `reversePrivateEndpointIds: null`, which the spec declares as a required +/// array but the server happily emits as null when unset. Response fields are +/// `Option`, so `null` lands as `None` rather than failing with +/// `invalid type: null, expected a sequence`. +#[tokio::test] +async fn list_click_pipes_with_null_array_fields() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/clickpipes")) + .respond_with(ok_json(serde_json::json!([ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "serviceId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + "name": "kafka-pipe", + "state": "Running", + "scaling": { + "replicas": 1, + "replicaCpuMillicores": 125, + "replicaMemoryGb": 0.5 + }, + "source": { + "kafka": { + "type": "confluent", + "format": "JSONEachRow", + "brokers": "broker.example:9092", + "topics": "events", + "consumerGroup": "clickpipes-aaaaaaaa", + "authentication": "PLAIN", + "reversePrivateEndpointIds": null + } + }, + "destination": { + "database": "default", + "table": "events", + "managedTable": true, + "tableDefinition": { + "engine": {"type": "MergeTree"}, + "sortingKey": ["id"] + }, + "columns": [] + }, + "fieldMappings": [], + "settings": {}, + "createdAt": "2026-05-13T17:47:28.132987Z", + "updatedAt": "2026-05-13T17:47:28.132987Z" + } + ]))) + .mount(&s) + .await; + + let resp = c.click_pipe_get_list("org-1", "svc-1").await.unwrap(); + let pipes = resp.result.unwrap(); + assert_eq!(pipes.len(), 1); + assert_eq!(pipes[0].name.as_deref(), Some("kafka-pipe")); + let kafka = pipes[0] + .source + .as_ref() + .and_then(|source| source.kafka.as_ref()) + .expect("kafka source present"); + assert_eq!(kafka.reverse_private_endpoint_ids, None); +} + +#[tokio::test] +async fn create_click_pipe() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/services/svc-1/clickpipes")) + .and(body_partial_json(serde_json::json!({"name": "new-pipe"}))) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "new-pipe", + "state": "Provisioning" + }))) + .mount(&s) + .await; + + let body = ClickPipePostRequest { + name: "new-pipe".to_string(), + ..Default::default() + }; + let resp = c.click_pipe_create("org-1", "svc-1", &body).await.unwrap(); + let pipe = resp.result.unwrap(); + assert_eq!(pipe.name.as_deref(), Some("new-pipe")); +} + +#[tokio::test] +async fn create_kinesis_click_pipe_sends_glue_registry() { + let (server, client) = setup().await; + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/services/svc-1/clickpipes")) + .and(body_partial_json(serde_json::json!({ + "source": {"kinesis": {"schemaRegistry": { + "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events" + }}} + }))) + .respond_with(ok_json(serde_json::json!({"name": "kinesis-pipe"}))) + .expect(1) + .mount(&server) + .await; + let request = ClickPipePostRequest { + name: "kinesis-pipe".into(), + source: ClickPipePostSource { + kinesis: Some(ClickPipePostKinesisSource { + schema_registry: Some(ClickPipeKinesisSchemaRegistry { + r#type: ClickPipeKinesisSchemaRegistryType::Glue, + glue_region: "us-east-1".into(), + glue_registry_name: "events".into(), + glue_role_arn: None, + }), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + let response = client + .click_pipe_create("org-1", "svc-1", &request) + .await + .unwrap(); + assert_eq!( + response.result.unwrap().name.as_deref(), + Some("kinesis-pipe") + ); +} + +#[tokio::test] +async fn get_click_pipe_preserves_nullable_kinesis_registry() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1", + )) + .respond_with(ok_json(serde_json::json!({ + "source": {"kinesis": {"schemaRegistry": { + "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": null + }}} + }))) + .mount(&server) + .await; + let pipe = client + .click_pipe_get("org-1", "svc-1", "pipe-1") + .await + .unwrap() + .result + .unwrap(); + let registry = pipe + .source + .unwrap() + .kinesis + .unwrap() + .schema_registry + .unwrap(); + assert_eq!(registry.glue_region.as_deref(), Some("us-east-1")); + assert_eq!(registry.glue_registry_name, None); + assert_eq!( + ClickPipeKinesisSchemaRegistry::try_from(registry) + .unwrap_err() + .fields(), + &["glueRegistryName"] + ); +} + +#[tokio::test] +async fn create_click_pipe_sends_start_paused_and_table_ttl() { + let (server, client) = setup().await; + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/services/svc-1/clickpipes")) + .and(body_partial_json(serde_json::json!({ + "startPaused": true, + "destination": {"tableDefinition": {"ttl": "event_time + INTERVAL 30 DAY"}} + }))) + .respond_with(ok_json(serde_json::json!({"state": "Stopped"}))) + .expect(1) + .mount(&server) + .await; + let request = ClickPipePostRequest { + start_paused: Some(true), + destination: ClickPipeMutateDestination { + table_definition: Some(ClickPipeDestinationTableDefinition { + ttl: "event_time + INTERVAL 30 DAY".to_string(), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + let response = client + .click_pipe_create("org-1", "svc-1", &request) + .await + .unwrap(); + assert_eq!( + response.result.unwrap().state, + Some(ClickPipeState::Stopped) + ); +} + +#[tokio::test] +async fn get_click_pipe() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "my-pipe", + "state": "Running" + }))) + .mount(&s) + .await; + + let resp = c.click_pipe_get("org-1", "svc-1", "pipe-1").await.unwrap(); + let pipe = resp.result.unwrap(); + assert_eq!(pipe.name.as_deref(), Some("my-pipe")); +} + +#[tokio::test] +async fn update_click_pipe() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1", + )) + .and(body_partial_json( + serde_json::json!({"name": "renamed-pipe"}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "renamed-pipe", + "state": "Running" + }))) + .mount(&s) + .await; + + let body = ClickPipePatchRequest { + name: Some("renamed-pipe".to_string()), + ..Default::default() + }; + let resp = c + .click_pipe_update("org-1", "svc-1", "pipe-1", &body) + .await + .unwrap(); + let pipe = resp.result.unwrap(); + assert_eq!(pipe.name.as_deref(), Some("renamed-pipe")); +} + +#[tokio::test] +async fn delete_click_pipe() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1", + )) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c + .click_pipe_delete("org-1", "svc-1", "pipe-1") + .await + .unwrap(); + assert_eq!(resp.status, Some(200)); +} + +#[tokio::test] +async fn update_click_pipe_state() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1/state", + )) + .and(body_partial_json(serde_json::json!({"command": "stop"}))) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "my-pipe", + "state": "Stopped" + }))) + .mount(&s) + .await; + + let body = ClickPipeStatePatchRequest { + command: Some(ClickPipeStatePatchRequestCommand::Stop), + }; + let resp = c + .click_pipe_state_update("org-1", "svc-1", "pipe-1", &body) + .await + .unwrap(); + let pipe = resp.result.unwrap(); + assert_eq!(pipe.state, Some(ClickPipeState::Stopped)); +} + +#[tokio::test] +async fn update_click_pipe_scaling() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1/scaling", + )) + .and(body_partial_json(serde_json::json!({}))) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "my-pipe", + "state": "Running" + }))) + .mount(&s) + .await; + + let body = ClickPipeScalingPatchRequest { + ..Default::default() + }; + let resp = c + .click_pipe_scaling_update("org-1", "svc-1", "pipe-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn get_click_pipe_settings() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1/settings", + )) + .respond_with(ok_json(serde_json::json!({}))) + .mount(&s) + .await; + + let resp = c + .click_pipe_settings_get("org-1", "svc-1", "pipe-1") + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn update_click_pipe_settings() { + let (s, c) = setup().await; + + Mock::given(method("PUT")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1/settings", + )) + .and(body_partial_json(serde_json::json!({}))) + .respond_with(ok_json(serde_json::json!({}))) + .mount(&s) + .await; + + let body = ClickPipeSettingsPutRequest { + kafka_read_committed: Some(true), + ..Default::default() + }; + let resp = c + .click_pipe_settings_update("org-1", "svc-1", "pipe-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +// =========================================================================== +// ClickPipes Schema Discovery (Beta) +// =========================================================================== + +#[tokio::test] +async fn click_pipe_schema_discovery_kafka() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipes/schemaDiscovery", + )) + .and(body_partial_json(serde_json::json!({ + "source": {"kafka": {"brokers": "broker1:9092"}} + }))) + .respond_with(ok_json(serde_json::json!({ + "fields": [ + {"name": "user_id", "type": "Int64", "optional": false}, + {"name": "event", "type": "String", "optional": true} + ] + }))) + .mount(&s) + .await; + + let body = ClickPipeSchemaDiscoveryRequest { + source: ClickPipeSchemaDiscoverySource { + kafka: Some(ClickPipePostKafkaSource { + brokers: "broker1:9092".to_string(), + ..Default::default() + }), + kinesis: None, + object_storage: None, + pubsub: None, + }, + }; + let resp = c + .click_pipe_schema_discovery("org-1", "svc-1", &body) + .await + .unwrap(); + let result = resp.result.unwrap(); + let fields = result.fields.expect("fields should populate"); + assert_eq!(fields.len(), 2); + assert_eq!(fields[0].name.as_deref(), Some("user_id")); + assert_eq!(fields[0].r#type.as_deref(), Some("Int64")); + assert_eq!(fields[1].optional, Some(true)); +} + +// =========================================================================== +// ClickPipes CDC Scaling +// =========================================================================== + +#[tokio::test] +async fn get_cdc_scaling() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipesCdcScaling", + )) + .respond_with(ok_json(serde_json::json!({ + "replicaCpuMillicores": 2000, + "replicaMemoryGb": 8.0 + }))) + .mount(&s) + .await; + + let resp = c + .click_pipe_cdc_scaling_get("org-1", "svc-1") + .await + .unwrap(); + let scaling = resp.result.unwrap(); + assert_eq!(scaling.replica_cpu_millicores, Some(2000)); + assert_eq!(scaling.replica_memory_gb, Some(8.0)); +} + +#[tokio::test] +async fn update_cdc_scaling() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipesCdcScaling", + )) + .and(body_partial_json( + serde_json::json!({"replicaCpuMillicores": 4000, "replicaMemoryGb": 16.0}), + )) + .respond_with(ok_json(serde_json::json!({ + "replicaCpuMillicores": 4000, + "replicaMemoryGb": 16.0 + }))) + .mount(&s) + .await; + + let body = ClickPipesCdcScalingPatchRequest { + replica_cpu_millicores: Some(4000), + replica_memory_gb: Some(16.0), + }; + let resp = c + .click_pipe_cdc_scaling_update("org-1", "svc-1", &body) + .await + .unwrap(); + let scaling = resp.result.unwrap(); + assert_eq!(scaling.replica_cpu_millicores, Some(4000)); +} + +// =========================================================================== +// ClickPipes Reverse Private Endpoints +// =========================================================================== + +#[tokio::test] +async fn list_reverse_private_endpoints() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipesReversePrivateEndpoints", + )) + .respond_with(ok_json(serde_json::json!([ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "description": "MSK endpoint", + "status": "available" + } + ]))) + .mount(&s) + .await; + + let resp = c + .click_pipe_reverse_private_endpoint_get_list("org-1", "svc-1") + .await + .unwrap(); + let endpoints = resp.result.unwrap(); + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0].description.as_deref(), Some("MSK endpoint")); +} + +#[tokio::test] +async fn create_reverse_private_endpoint() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipesReversePrivateEndpoints", + )) + .and(body_partial_json( + serde_json::json!({"description": "New RPE"}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "description": "New RPE", + "status": "creating" + }))) + .mount(&s) + .await; + + let body = CreateReversePrivateEndpoint { + description: "New RPE".to_string(), + ..Default::default() + }; + let resp = c + .click_pipe_reverse_private_endpoint_create("org-1", "svc-1", &body) + .await + .unwrap(); + let rpe = resp.result.unwrap(); + assert_eq!(rpe.description.as_deref(), Some("New RPE")); +} + +#[tokio::test] +async fn get_reverse_private_endpoint() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipesReversePrivateEndpoints/rpe-1", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "description": "My RPE", + "status": "available" + }))) + .mount(&s) + .await; + + let resp = c + .click_pipe_reverse_private_endpoint_get("org-1", "svc-1", "rpe-1") + .await + .unwrap(); + let rpe = resp.result.unwrap(); + assert_eq!(rpe.description.as_deref(), Some("My RPE")); +} + +#[tokio::test] +async fn delete_reverse_private_endpoint() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickpipesReversePrivateEndpoints/rpe-1", + )) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c + .click_pipe_reverse_private_endpoint_delete("org-1", "svc-1", "rpe-1") + .await + .unwrap(); + assert_eq!(resp.status, Some(200)); +} + +#[tokio::test] +async fn clickpipes_context_returns_workload_identity_and_tolerates_omissions() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org/services/service/clickpipes/context")) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!({"gcpWorkloadIdentity": { + "supported": true, "ready": null, "principal": "clickpipes@example.iam.gserviceaccount.com" + }}))) + .expect(1).mount(&server).await; + let result = client + .click_pipes_service_context_get("org", "service") + .await + .unwrap() + .result + .unwrap(); + let identity = result.gcp_workload_identity.unwrap(); + assert_eq!(identity.supported, Some(true)); + assert_eq!(identity.ready, None); + assert_eq!( + identity.principal.as_deref(), + Some("clickpipes@example.iam.gserviceaccount.com") + ); +} diff --git a/crates/clickhouse-cloud-api/tests/client/clickstack.rs b/crates/clickhouse-cloud-api/tests/client/clickstack.rs new file mode 100644 index 00000000..c4aacaae --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/clickstack.rs @@ -0,0 +1,1196 @@ +use clickhouse_cloud_api::models::*; +use wiremock::matchers::{basic_auth, body_json, body_partial_json, method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::{ok_empty, ok_json, setup}; + +// =========================================================================== +// ClickStack: Alerts +// =========================================================================== + +#[tokio::test] +async fn list_alerts() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/alerts", + )) + .respond_with(ok_json(serde_json::json!([ + { + "id": "alert-1", + "name": "High CPU" + } + ]))) + .mount(&s) + .await; + + let resp = c + .click_stack_list_alerts("org-1", "svc-1", None, None) + .await + .unwrap(); + let alerts = resp.result.unwrap(); + assert_eq!(alerts.len(), 1); +} + +#[tokio::test] +async fn create_alert() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/alerts", + )) + .and(body_partial_json(serde_json::json!({"name": "New Alert"}))) + .respond_with(ok_json(serde_json::json!({ + "id": "alert-1", + "name": "New Alert" + }))) + .mount(&s) + .await; + + let body = ClickStackCreateAlertRequest { + name: Some("New Alert".to_string()), + ..Default::default() + }; + let resp = c + .click_stack_create_alert("org-1", "svc-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn create_inline_alert_sends_chart_config_and_reads_it_back() { + let (s, c) = setup().await; + let chart_config = serde_json::json!({ + "displayType": "number", "configType": "sql", "connectionId": "conn-1", + "sqlTemplate": "SELECT count() FROM otel_logs", "name": "Error count" + }); + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/alerts", + )) + .and(body_json(serde_json::json!({ + "source": "inline", "chartConfig": chart_config, "threshold": 5.0, + "interval": "5m", "thresholdType": "above", "displayName": "Errors", + "tags": ["p1"] + }))) + .respond_with(ok_json(serde_json::json!({ + "id": "alert-1", "source": "inline", "chartConfig": chart_config, + "displayName": "Errors", "tags": ["p1"] + }))) + .mount(&s) + .await; + + let body = ClickStackCreateAlertRequest { + source: Some(ClickStackCreateAlertRequestSource::Inline), + chart_config: Some( + ClickStackAlertChartConfig::ClickStackAlertNumberChartConfig( + ClickStackAlertNumberChartConfig::ClickStackAlertNumberRawSqlChartConfig( + ClickStackAlertNumberRawSqlChartConfig { + connection_id: "conn-1".into(), + sql_template: "SELECT count() FROM otel_logs".into(), + name: Some("Error count".into()), + ..Default::default() + }, + ), + ), + ), + threshold: 5.0, + interval: ClickStackCreateAlertRequestInterval::_5m, + display_name: Some("Errors".into()), + tags: Some(vec!["p1".into()]), + ..Default::default() + }; + let alert = c + .click_stack_create_alert("org-1", "svc-1", &body) + .await + .unwrap() + .result + .unwrap(); + assert_eq!(alert.source, Some(ClickStackAlertResponseSource::Inline)); + assert_eq!(alert.display_name.as_deref(), Some("Errors")); + assert!(matches!( + alert.chart_config, + Some( + ClickStackAlertChartConfigResponse::ClickStackAlertNumberChartConfig( + ClickStackAlertNumberChartConfigResponse::ClickStackAlertNumberRawSqlChartConfig(_) + ) + ) + )); +} + +#[tokio::test] +async fn get_alert() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/alerts/alert-1", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "alert-1", + "name": "My Alert" + }))) + .mount(&s) + .await; + + let resp = c + .click_stack_get_alert("org-1", "svc-1", "alert-1") + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn update_alert() { + let (s, c) = setup().await; + + Mock::given(method("PUT")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/alerts/alert-1", + )) + .and(body_partial_json( + serde_json::json!({"name": "Updated Alert"}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "alert-1", + "name": "Updated Alert" + }))) + .mount(&s) + .await; + + let body = ClickStackUpdateAlertRequest { + name: Some("Updated Alert".to_string()), + ..Default::default() + }; + let resp = c + .click_stack_update_alert("org-1", "svc-1", "alert-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn delete_alert() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/alerts/alert-1", + )) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c + .click_stack_delete_alert("org-1", "svc-1", "alert-1") + .await + .unwrap(); + assert_eq!(resp.status, Some(200)); +} + +// =========================================================================== +// ClickStack: Saved Searches +// =========================================================================== + +#[tokio::test] +async fn list_saved_searches() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/saved-searches", + )) + .respond_with(ok_json(serde_json::json!([ + { + "id": "search-1", + "name": "Production Errors", + "sourceId": "source-1" + } + ]))) + .mount(&s) + .await; + + let resp = c + .click_stack_list_saved_searches("org-1", "svc-1", None, None) + .await + .unwrap(); + let searches = resp.result.unwrap(); + assert_eq!(searches.len(), 1); + assert_eq!(searches[0].source_id.as_deref(), Some("source-1")); +} + +#[tokio::test] +async fn create_saved_search() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/saved-searches", + )) + .and(body_partial_json(serde_json::json!({ + "name": "Production Errors", + "sourceId": "source-1" + }))) + .respond_with(ok_json(serde_json::json!({ + "id": "search-1", + "name": "Production Errors", + "sourceId": "source-1" + }))) + .mount(&s) + .await; + + let body = ClickStackSavedSearchInput { + name: "Production Errors".to_string(), + source_id: "source-1".to_string(), + ..Default::default() + }; + let resp = c + .click_stack_create_saved_search("org-1", "svc-1", &body) + .await + .unwrap(); + assert_eq!(resp.result.unwrap().id.as_deref(), Some("search-1")); +} + +#[tokio::test] +async fn get_saved_search() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/saved-searches/search-1", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "search-1", + "name": "Production Errors", + "sourceId": "source-1" + }))) + .mount(&s) + .await; + + let resp = c + .click_stack_get_saved_search("org-1", "svc-1", "search-1") + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn update_saved_search() { + let (s, c) = setup().await; + + Mock::given(method("PUT")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/saved-searches/search-1", + )) + .and(body_partial_json(serde_json::json!({ + "name": "Updated Search", + "sourceId": "source-1" + }))) + .respond_with(ok_json(serde_json::json!({ + "id": "search-1", + "name": "Updated Search", + "sourceId": "source-1" + }))) + .mount(&s) + .await; + + let body = ClickStackSavedSearchInput { + name: "Updated Search".to_string(), + source_id: "source-1".to_string(), + ..Default::default() + }; + let resp = c + .click_stack_update_saved_search("org-1", "svc-1", "search-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn delete_saved_search() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/saved-searches/search-1", + )) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c + .click_stack_delete_saved_search("org-1", "svc-1", "search-1") + .await + .unwrap(); + assert_eq!(resp.status, Some(200)); +} + +// =========================================================================== +// ClickStack: Dashboards +// =========================================================================== + +#[tokio::test] +async fn list_dashboards() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/dashboards", + )) + .respond_with(ok_json(serde_json::json!([ + { + "id": "dash-1", + "name": "Overview" + } + ]))) + .mount(&s) + .await; + + let resp = c + .click_stack_list_dashboards("org-1", "svc-1") + .await + .unwrap(); + let dashboards = resp.result.unwrap(); + assert_eq!(dashboards.len(), 1); +} + +#[tokio::test] +async fn create_dashboard() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/dashboards", + )) + .and(body_partial_json( + serde_json::json!({"name": "New Dashboard", "tiles": []}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "dash-new", + "name": "New Dashboard" + }))) + .mount(&s) + .await; + + let body = ClickStackCreateDashboardRequest { + name: "New Dashboard".to_string(), + tiles: vec![], + ..Default::default() + }; + let resp = c + .click_stack_create_dashboard("org-1", "svc-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn get_dashboard() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/dashboards/dash-1", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "dash-1", + "name": "My Dashboard" + }))) + .mount(&s) + .await; + + let resp = c + .click_stack_get_dashboard("org-1", "svc-1", "dash-1") + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn update_dashboard() { + let (s, c) = setup().await; + + Mock::given(method("PUT")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/dashboards/dash-1", + )) + .and(body_partial_json( + serde_json::json!({"name": "Updated Dashboard", "tiles": []}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "dash-1", + "name": "Updated Dashboard" + }))) + .mount(&s) + .await; + + let body = ClickStackUpdateDashboardRequest { + name: "Updated Dashboard".to_string(), + tiles: vec![], + ..Default::default() + }; + let resp = c + .click_stack_update_dashboard("org-1", "svc-1", "dash-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn delete_dashboard() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/dashboards/dash-1", + )) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c + .click_stack_delete_dashboard("org-1", "svc-1", "dash-1") + .await + .unwrap(); + assert_eq!(resp.status, Some(200)); +} + +// =========================================================================== +// ClickStack: Sources & Webhooks +// =========================================================================== + +#[tokio::test] +async fn list_sources() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/sources", + )) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + let resp = c.click_stack_list_sources("org-1", "svc-1").await.unwrap(); + let sources = resp.result.unwrap(); + assert_eq!(sources.len(), 0); +} + +#[tokio::test] +async fn create_source() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/sources", + )) + .and(body_partial_json( + serde_json::json!({"kind": "promql", "name": "Prometheus Metrics"}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "source-1", + "kind": "promql", + "name": "Prometheus Metrics", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "metrics"}, + "timestampValueExpression": "timestamp" + }))) + .mount(&s) + .await; + + let body = ClickStackSource::ClickStackPromqlSource(ClickStackPromqlSource { + name: "Prometheus Metrics".to_string(), + kind: ClickStackPromqlSourceKind::Promql, + connection: "conn-1".to_string(), + from: ClickStackSourceFrom { + database_name: "default".to_string(), + table_name: "metrics".to_string(), + }, + timestamp_value_expression: "timestamp".to_string(), + ..Default::default() + }); + let resp = c + .click_stack_create_source("org-1", "svc-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn get_source() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/sources/source-1", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "source-1", + "kind": "promql", + "name": "My Source", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "metrics"}, + "timestampValueExpression": "timestamp" + }))) + .mount(&s) + .await; + + let resp = c + .click_stack_get_source("org-1", "svc-1", "source-1") + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn update_source() { + let (s, c) = setup().await; + + Mock::given(method("PUT")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/sources/source-1", + )) + .and(body_partial_json( + serde_json::json!({"name": "Updated Source"}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "source-1", + "kind": "promql", + "name": "Updated Source", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "metrics"}, + "timestampValueExpression": "timestamp" + }))) + .mount(&s) + .await; + + let body = ClickStackSource::ClickStackPromqlSource(ClickStackPromqlSource { + name: "Updated Source".to_string(), + kind: ClickStackPromqlSourceKind::Promql, + connection: "conn-1".to_string(), + from: ClickStackSourceFrom { + database_name: "default".to_string(), + table_name: "metrics".to_string(), + }, + timestamp_value_expression: "timestamp".to_string(), + ..Default::default() + }); + let resp = c + .click_stack_update_source("org-1", "svc-1", "source-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn delete_source() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/sources/source-1", + )) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c + .click_stack_delete_source("org-1", "svc-1", "source-1") + .await + .unwrap(); + assert_eq!(resp.status, Some(200)); +} + +#[tokio::test] +async fn list_webhooks() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/webhooks", + )) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + let resp = c + .click_stack_list_webhooks("org-1", "svc-1", None, None) + .await + .unwrap(); + let webhooks = resp.result.unwrap(); + assert_eq!(webhooks.len(), 0); +} + +// =========================================================================== +// ClickStack: Roles +// =========================================================================== + +#[tokio::test] +async fn list_roles() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/roles", + )) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + let resp = c.click_stack_list_roles("org-1", "svc-1").await.unwrap(); + let roles = resp.result.unwrap(); + assert_eq!(roles.len(), 0); +} + +#[tokio::test] +async fn create_role() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/roles", + )) + .and(body_partial_json(serde_json::json!({ + "name": "Deploy Bot", + "permissions": [{ + "action": "read", + "subject": "Dashboard", + "integration": "mongodb", + "conditions": { "team": "team-1" } + }] + }))) + .respond_with(ok_json(serde_json::json!({ + "id": "role-1", + "name": "Deploy Bot", + "isPredefined": false, + "permissions": [{ + "action": "read", + "subject": "Dashboard", + "integration": "mongodb", + "conditions": { "team": "team-1" } + }] + }))) + .mount(&s) + .await; + + let body = ClickStackCreateRoleRequest { + name: "Deploy Bot".to_string(), + permissions: vec![ClickStackCASLPermission::ClickStackMongoDBCASLPermission( + ClickStackMongoDBCASLPermission { + action: ClickStackCASLPermissionAction::Read, + subject: ClickStackCASLPermissionSubject::Dashboard, + conditions: Some( + [( + "team".to_string(), + ClickStackMongoDBCASLPermissionConditionValue::String("team-1".to_string()), + )] + .into(), + ), + ..Default::default() + }, + )], + ..Default::default() + }; + let resp = c + .click_stack_create_role("org-1", "svc-1", &body) + .await + .unwrap(); + let role = resp.result.unwrap(); + assert_eq!(role.id.as_deref(), Some("role-1")); + assert_eq!(role.name.as_deref(), Some("Deploy Bot")); + assert_eq!(role.is_predefined, Some(false)); + assert!(matches!( + role.permissions.as_deref(), + Some([ClickStackCASLPermissionResponse::ClickStackMongoDBCASLPermission(_)]) + )); +} + +#[tokio::test] +async fn get_role() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/roles/role-1", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "role-1", + "name": "Read Only", + "isPredefined": true, + "permissions": [{ "action": "read", "subject": "Dashboard", "integration": "mongodb" }] + }))) + .mount(&s) + .await; + + let resp = c + .click_stack_get_role("org-1", "svc-1", "role-1") + .await + .unwrap(); + let role = resp.result.unwrap(); + assert_eq!(role.id.as_deref(), Some("role-1")); + assert_eq!(role.is_predefined, Some(true)); +} + +#[tokio::test] +async fn update_role() { + let (s, c) = setup().await; + + Mock::given(method("PUT")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/roles/role-1", + )) + .and(body_partial_json(serde_json::json!({ + "permissions": [{ "action": "manage", "subject": "all", "integration": "clickhouse" }] + }))) + .respond_with(ok_json(serde_json::json!({ + "id": "role-1", + "name": "Deploy Bot", + "isPredefined": false, + "permissions": [{ "action": "manage", "subject": "all", "integration": "clickhouse" }] + }))) + .mount(&s) + .await; + + let body = ClickStackUpdateRoleRequest { + permissions: vec![ + ClickStackCASLPermission::ClickStackClickHouseCASLPermission( + ClickStackClickHouseCASLPermission { + action: ClickStackCASLPermissionAction::Manage, + subject: ClickStackCASLPermissionSubject::All, + ..Default::default() + }, + ), + ], + ..Default::default() + }; + let resp = c + .click_stack_update_role("org-1", "svc-1", "role-1", &body) + .await + .unwrap(); + let role = resp.result.unwrap(); + let permissions = role.permissions.unwrap_or_default(); + assert_eq!(permissions.len(), 1); + let ClickStackCASLPermissionResponse::ClickStackClickHouseCASLPermission(permission) = + &permissions[0] + else { + panic!("expected the ClickHouse variant, got {:?}", permissions[0]); + }; + assert_eq!( + permission.action, + Some(ClickStackCASLPermissionAction::Manage) + ); +} + +#[tokio::test] +async fn delete_role() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/roles/role-1", + )) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c + .click_stack_delete_role("org-1", "svc-1", "role-1") + .await + .unwrap(); + assert_eq!(resp.status, Some(200)); +} + +// =========================================================================== +// ClickStack: Webhooks & Dashboard validation +// =========================================================================== + +#[tokio::test] +async fn create_webhook() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/webhooks", + )) + .and(body_partial_json(serde_json::json!({ + "name": "Production Alerts", + "service": "slack", + "url": "https://hooks.slack.com/services/T/B/X" + }))) + .respond_with(ok_json(serde_json::json!({ + "id": "webhook-1", + "name": "Production Alerts", + "service": "slack", + "url": "https://hooks.slack.com/services/T/B/X", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-06-15T10:30:00.000Z" + }))) + .mount(&s) + .await; + + let body = ClickStackWebhookInput { + name: "Production Alerts".to_string(), + service: ClickStackWebhookInputService::Slack, + url: "https://hooks.slack.com/services/T/B/X".to_string(), + ..Default::default() + }; + let resp = c + .click_stack_create_webhook("org-1", "svc-1", &body) + .await + .unwrap(); + assert!(resp.result.is_some()); +} + +#[tokio::test] +async fn update_webhook() { + let (s, c) = setup().await; + + Mock::given(method("PUT")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/webhook-1", + )) + .and(body_partial_json(serde_json::json!({ + "name": "Updated Alerts" + }))) + .respond_with(ok_json(serde_json::json!({ + "id": "webhook-1", + "name": "Updated Alerts", + "service": "slack", + "url": "https://hooks.slack.com/services/T/B/X", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-06-15T10:30:00.000Z" + }))) + .mount(&s) + .await; + + let body = ClickStackWebhookInput { + name: "Updated Alerts".to_string(), + service: ClickStackWebhookInputService::Slack, + url: "https://hooks.slack.com/services/T/B/X".to_string(), + ..Default::default() + }; + let resp = c + .click_stack_update_webhook("org-1", "svc-1", "webhook-1", &body) + .await + .unwrap(); + // The response union resolves to a concrete Slack webhook variant. + match resp.result.unwrap() { + ClickStackWebhook::ClickStackSlackWebhook(w) => { + assert_eq!(w.id.as_deref(), Some("webhook-1")); + assert_eq!(w.name.as_deref(), Some("Updated Alerts")); + } + other => panic!("expected Slack webhook variant, got {other}"), + } +} + +#[tokio::test] +async fn create_webhook_incidentio_response() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/webhooks", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "webhook-2", + "name": "Incident Alerts", + "service": "incidentio", + "url": "https://api.incident.io/v2/alert_events/http/abc", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-06-15T10:30:00.000Z" + }))) + .mount(&s) + .await; + + let body = ClickStackWebhookInput { + name: "Incident Alerts".to_string(), + service: ClickStackWebhookInputService::Incidentio, + url: "https://api.incident.io/v2/alert_events/http/abc".to_string(), + ..Default::default() + }; + let resp = c + .click_stack_create_webhook("org-1", "svc-1", &body) + .await + .unwrap(); + // The response union resolves to a concrete IncidentIO webhook variant + // rather than greedily matching the structurally-identical Slack variant. + match resp.result.unwrap() { + ClickStackWebhook::ClickStackIncidentIOWebhook(w) => { + assert_eq!(w.id.as_deref(), Some("webhook-2")); + assert_eq!(w.name.as_deref(), Some("Incident Alerts")); + } + other => panic!("expected IncidentIO webhook variant, got {other}"), + } +} + +#[tokio::test] +async fn update_webhook_generic_response() { + let (s, c) = setup().await; + + Mock::given(method("PUT")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/webhook-3", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "webhook-3", + "name": "Generic Alerts", + "service": "generic", + "url": "https://example.com/hook", + "body": "{\"text\": \"{{ message }}\"}", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-06-15T10:30:00.000Z" + }))) + .mount(&s) + .await; + + let body = ClickStackWebhookInput { + name: "Generic Alerts".to_string(), + service: ClickStackWebhookInputService::Generic, + url: "https://example.com/hook".to_string(), + ..Default::default() + }; + let resp = c + .click_stack_update_webhook("org-1", "svc-1", "webhook-3", &body) + .await + .unwrap(); + // The response union resolves to the Generic variant and preserves its + // optional `body` field, which the greedy Slack match would have discarded. + match resp.result.unwrap() { + ClickStackWebhook::ClickStackGenericWebhook(w) => { + assert_eq!(w.id.as_deref(), Some("webhook-3")); + assert_eq!(w.body.as_deref(), Some("{\"text\": \"{{ message }}\"}")); + } + other => panic!("expected Generic webhook variant, got {other}"), + } +} + +#[tokio::test] +async fn delete_webhook() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/webhook-1", + )) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c + .click_stack_delete_webhook("org-1", "svc-1", "webhook-1") + .await + .unwrap(); + assert_eq!(resp.status, Some(200)); +} + +#[tokio::test] +async fn validate_dashboard() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/dashboards/validate", + )) + .and(body_partial_json(serde_json::json!({ + "name": "My Dashboard" + }))) + .respond_with(ok_json(serde_json::json!({ + "valid": false, + "errors": [ + {"path": "tiles.0.config", "message": "Required"} + ], + "normalized": null + }))) + .mount(&s) + .await; + + let body = ClickStackCreateDashboardRequest { + name: "My Dashboard".to_string(), + ..Default::default() + }; + let resp = c + .click_stack_validate_dashboard("org-1", "svc-1", &body) + .await + .unwrap(); + let result = resp.result.unwrap(); + assert_eq!(result.valid, Some(false)); + let errors = result.errors.as_ref().expect("errors should populate"); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].path.as_deref(), Some("tiles.0.config")); + assert_eq!(result.normalized, None); +} + +#[tokio::test] +async fn validate_webhook() { + let (s, c) = setup().await; + + // The full body is matched, so optional fields left as `None` must be + // omitted from the request rather than sent as `null`. + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/validate", + )) + .and(body_json(serde_json::json!({ + "name": "Generic Alerts", + "service": "generic", + "url": "ftp://example.com/hook", + "body": "{{title}}" + }))) + .respond_with(ok_json(serde_json::json!({ + "valid": false, + "errors": [ + {"path": "url", "message": "Invalid url"} + ], + "normalized": null + }))) + .mount(&s) + .await; + + let body = ClickStackWebhookInput { + name: "Generic Alerts".to_string(), + service: ClickStackWebhookInputService::Generic, + url: "ftp://example.com/hook".to_string(), + body: Some("{{title}}".to_string()), + ..Default::default() + }; + let resp = c + .click_stack_validate_webhook("org-1", "svc-1", &body) + .await + .unwrap(); + let result = resp.result.unwrap(); + assert_eq!(result.valid, Some(false)); + let errors = result.errors.as_ref().expect("errors should populate"); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].path.as_deref(), Some("url")); + assert_eq!(errors[0].message.as_deref(), Some("Invalid url")); + assert_eq!(result.normalized, None); +} + +#[tokio::test] +async fn validate_webhook_returns_the_normalized_webhook() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/validate", + )) + .respond_with(ok_json(serde_json::json!({ + "valid": true, + "errors": [], + "normalized": { + "name": "Production Alerts", + "service": "slack", + "url": "https://hooks.slack.com/services/T/B/X" + } + }))) + .mount(&s) + .await; + + let body = ClickStackWebhookInput { + name: "Production Alerts".to_string(), + service: ClickStackWebhookInputService::Slack, + url: "https://hooks.slack.com/services/T/B/X".to_string(), + ..Default::default() + }; + let resp = c + .click_stack_validate_webhook("org-1", "svc-1", &body) + .await + .unwrap(); + let result = resp.result.unwrap(); + assert_eq!(result.valid, Some(true)); + assert_eq!(result.errors.as_deref(), Some(&[][..])); + let normalized = result.normalized.expect("normalized should populate"); + assert_eq!(normalized["service"], "slack"); +} + +#[tokio::test] +async fn validate_webhook_surfaces_a_bad_request() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/validate", + )) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "status": 400, + "error": "Invalid request body" + }))) + .mount(&s) + .await; + + let err = c + .click_stack_validate_webhook("org-1", "svc-1", &ClickStackWebhookInput::default()) + .await + .expect_err("a 400 must fail the call"); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 400); + assert_eq!(message, "Invalid request body"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn clickstack_list_pagination_encodes_only_supplied_parameters() { + for resource in ["alerts", "saved-searches", "webhooks"] { + for (limit, offset) in [ + (None, None), + (Some(25), None), + (None, Some(50)), + (Some(25), Some(50)), + ] { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path(format!( + "/v1/organizations/org-1/services/svc-1/clickstack/{resource}" + ))) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!([{}]))) + .expect(1) + .mount(&server) + .await; + let count = match resource { + "alerts" => client + .click_stack_list_alerts("org-1", "svc-1", limit, offset) + .await + .unwrap() + .result + .unwrap() + .len(), + "saved-searches" => client + .click_stack_list_saved_searches("org-1", "svc-1", limit, offset) + .await + .unwrap() + .result + .unwrap() + .len(), + "webhooks" => client + .click_stack_list_webhooks("org-1", "svc-1", limit, offset) + .await + .unwrap() + .result + .unwrap() + .len(), + _ => unreachable!(), + }; + assert_eq!(count, 1); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + let actual: std::collections::BTreeMap<_, _> = requests[0] + .url + .query_pairs() + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + let expected: std::collections::BTreeMap<_, _> = [("limit", limit), ("offset", offset)] + .into_iter() + .filter_map(|(key, value)| value.map(|value| (key.to_owned(), value.to_string()))) + .collect(); + assert_eq!(actual, expected, "{resource}"); + } + } +} diff --git a/crates/clickhouse-cloud-api/tests/client/client.rs b/crates/clickhouse-cloud-api/tests/client/client.rs new file mode 100644 index 00000000..4d39e3c2 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/client.rs @@ -0,0 +1,417 @@ +use clickhouse_cloud_api::{Client, models::*}; +use wiremock::matchers::{basic_auth, bearer_token, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::{ok_json, setup}; + +// =========================================================================== +// Authentication +// =========================================================================== + +#[tokio::test] +async fn bearer_auth_sends_token() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .and(bearer_token("my-oauth-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Bearer Org" + } + ] + }))) + .mount(&mock_server) + .await; + + let client = Client::with_bearer_token(mock_server.uri(), "my-oauth-token"); + let resp = client.organization_get_list(None, None).await.unwrap(); + let orgs = resp.result.unwrap(); + assert_eq!(orgs.len(), 1); + assert_eq!(orgs[0].name.as_deref(), Some("Bearer Org")); +} + +#[tokio::test] +async fn set_bearer_token_updates_token() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .and(bearer_token("refreshed-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": [] + }))) + .mount(&mock_server) + .await; + + let mut client = Client::with_bearer_token(mock_server.uri(), "old-token"); + client.set_bearer_token("refreshed-token").unwrap(); + let resp = client.organization_get_list(None, None).await.unwrap(); + assert_eq!(resp.result.unwrap().len(), 0); +} + +#[tokio::test] +async fn with_http_client_basic_auth() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .and(basic_auth("custom-key", "custom-secret")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&mock_server) + .await; + + let http = reqwest::Client::new(); + let client = Client::with_http_client(http, mock_server.uri(), "custom-key", "custom-secret"); + let resp = client.organization_get_list(None, None).await.unwrap(); + assert_eq!(resp.result.unwrap().len(), 0); +} + +#[tokio::test] +async fn with_http_client_bearer_auth() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .and(bearer_token("custom-bearer")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&mock_server) + .await; + + let http = reqwest::Client::new(); + let client = Client::with_http_client_bearer(http, mock_server.uri(), "custom-bearer"); + let resp = client.organization_get_list(None, None).await.unwrap(); + assert_eq!(resp.result.unwrap().len(), 0); +} + +#[test] +fn set_bearer_token_errors_on_basic_auth() { + let mut client = Client::new("key", "secret"); + let err = client.set_bearer_token("token").unwrap_err(); + assert!( + err.to_string().contains("auth mismatch"), + "unexpected error: {err}" + ); +} + +// =========================================================================== +// Error handling +// =========================================================================== + +#[tokio::test] +async fn api_error_response() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "status": 401, + "error": "Invalid credentials", + "requestId": "req-err-123" + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "bad-key", "bad-secret"); + let err = client.organization_get_list(None, None).await.unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 401); + assert_eq!(message, "Invalid credentials"); + } + other => panic!("Expected Api error, got: {:?}", other), + } +} + +#[tokio::test] +async fn api_error_403_forbidden() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/services")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "status": 403, + "error": "Forbidden: insufficient permissions" + }))) + .mount(&s) + .await; + + let body = ServicePostRequest { + name: "test".to_string(), + ..Default::default() + }; + let err = c.instance_create("org-1", &body).await.unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 403); + assert!(message.contains("Forbidden")); + } + other => panic!("Expected Api error, got: {:?}", other), + } +} + +#[tokio::test] +async fn api_error_404_not_found() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/nonexistent")) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "status": 404, + "error": "Service not found" + }))) + .mount(&s) + .await; + + let err = c.instance_get("org-1", "nonexistent").await.unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 404); + assert_eq!(message, "Service not found"); + } + other => panic!("Expected Api error, got: {:?}", other), + } +} + +#[tokio::test] +async fn api_error_500_server_error() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "status": 500, + "error": "Internal server error" + }))) + .mount(&s) + .await; + + let err = c.organization_get_list(None, None).await.unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 500); + assert_eq!(message, "Internal server error"); + } + other => panic!("Expected Api error, got: {:?}", other), + } +} + +#[tokio::test] +async fn api_error_non_json_body() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway")) + .mount(&s) + .await; + + let err = c.organization_get_list(None, None).await.unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 502); + // Falls back to raw body text when JSON parsing fails + assert_eq!(message, "Bad Gateway"); + } + other => panic!("Expected Api error, got: {:?}", other), + } +} + +#[tokio::test] +async fn api_error_on_prometheus_endpoint() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/prometheus")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "status": 403, + "error": "Metrics access denied" + }))) + .mount(&s) + .await; + + let err = c + .organization_prometheus_get("org-1", None) + .await + .unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 403); + assert_eq!(message, "Metrics access denied"); + } + other => panic!("Expected Api error, got: {:?}", other), + } +} + +#[tokio::test] +async fn api_error_on_postgres_certs_endpoint() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/postgres/pg-1/caCertificates")) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "error": "Postgres service not found" + }))) + .mount(&s) + .await; + + let err = c + .postgres_service_certs_get("org-1", "pg-1") + .await + .unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 404); + assert_eq!(message, "Postgres service not found"); + } + other => panic!("Expected Api error, got: {:?}", other), + } +} + +// =========================================================================== +// Malformed responses (Error::Json coverage) +// =========================================================================== + +#[tokio::test] +async fn malformed_json_success_response() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .respond_with(ResponseTemplate::new(200).set_body_string("this is not json")) + .mount(&s) + .await; + + let err = c.organization_get_list(None, None).await.unwrap_err(); + match err { + clickhouse_cloud_api::Error::Json(_) => {} // expected + other => panic!("Expected Json error, got: {:?}", other), + } +} + +#[tokio::test] +async fn truncated_json_success_response() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": 200, "result":"#)) + .mount(&s) + .await; + + let err = c.organization_get_list(None, None).await.unwrap_err(); + match err { + clickhouse_cloud_api::Error::Json(_) => {} // expected + other => panic!("Expected Json error, got: {:?}", other), + } +} + +// =========================================================================== +// Additional HTTP status codes +// =========================================================================== + +#[tokio::test] +async fn api_error_429_rate_limited() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .respond_with(ResponseTemplate::new(429).set_body_json(serde_json::json!({ + "status": 429, + "error": "Rate limit exceeded" + }))) + .mount(&s) + .await; + + let err = c.organization_get_list(None, None).await.unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 429); + assert_eq!(message, "Rate limit exceeded"); + } + other => panic!("Expected Api error, got: {:?}", other), + } +} + +#[tokio::test] +async fn api_error_422_validation() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/services")) + .respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({ + "status": 422, + "error": "Validation failed: name is required" + }))) + .mount(&s) + .await; + + let body = ServicePostRequest::default(); + let err = c.instance_create("org-1", &body).await.unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 422); + assert!(message.contains("Validation failed")); + } + other => panic!("Expected Api error, got: {:?}", other), + } +} + +// =========================================================================== +// Base URL handling +// =========================================================================== + +#[tokio::test] +async fn base_url_trailing_slash_stripped() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&mock_server) + .await; + + let url_with_slash = format!("{}/", mock_server.uri()); + let client = Client::with_base_url(url_with_slash, "key", "secret"); + let resp = client.organization_get_list(None, None).await.unwrap(); + assert_eq!(resp.result.unwrap().len(), 0); +} + +#[tokio::test] +async fn default_base_url_is_production() { + // Client::new() uses https://api.clickhouse.cloud -- we can't hit it, + // but we can verify the client is constructable without panicking. + let _client = Client::new("key", "secret"); +} + +#[tokio::test] +async fn new_discovery_operations_preserve_api_errors() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(403).set_body_json(serde_json::json!({"error": "forbidden"})), + ) + .expect(3) + .mount(&server) + .await; + let errors = [ + client.credit_balances_get("org").await.unwrap_err(), + client + .service_profiles_list("org", Some("region"), None) + .await + .unwrap_err(), + client + .click_pipes_service_context_get("org", "service") + .await + .unwrap_err(), + ]; + for error in errors { + assert!( + matches!(error, clickhouse_cloud_api::Error::Api { status: 403, message } if message == "forbidden") + ); + } +} diff --git a/crates/clickhouse-cloud-api/tests/client/invitations.rs b/crates/clickhouse-cloud-api/tests/client/invitations.rs new file mode 100644 index 00000000..99747468 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/invitations.rs @@ -0,0 +1,98 @@ +use clickhouse_cloud_api::{Client, models::*}; +use wiremock::matchers::{body_partial_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::{ok_empty, ok_json, setup}; + +// =========================================================================== +// Invitations +// =========================================================================== + +#[tokio::test] +async fn list_invitations() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/invitations")) + .respond_with(ok_json(serde_json::json!([ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "email": "alice@example.com", + "role": "developer" + } + ]))) + .mount(&s) + .await; + + let resp = c.invitation_get_list("org-1").await.unwrap(); + let invitations = resp.result.unwrap(); + assert_eq!(invitations.len(), 1); + assert_eq!(invitations[0].email.as_deref(), Some("alice@example.com")); +} + +#[tokio::test] +async fn create_invitation() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/invitations")) + .and(body_partial_json( + serde_json::json!({"email": "newuser@example.com"}), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": { + "id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + "email": "newuser@example.com", + "role": "developer" + } + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let body = InvitationPostRequest { + email: "newuser@example.com".to_string(), + #[cfg(feature = "deprecated-fields")] + role: Some(InvitationPostRequestRole::Developer), + ..Default::default() + }; + let resp = client.invitation_create("org-1", &body).await.unwrap(); + let inv = resp.result.unwrap(); + assert_eq!(inv.email.as_deref(), Some("newuser@example.com")); +} + +#[tokio::test] +async fn get_invitation() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/invitations/inv-1")) + .respond_with(ok_json(serde_json::json!({ + "id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + "email": "bob@example.com", + "role": "admin" + }))) + .mount(&s) + .await; + + let resp = c.invitation_get("org-1", "inv-1").await.unwrap(); + let inv = resp.result.unwrap(); + assert_eq!(inv.email.as_deref(), Some("bob@example.com")); + #[cfg(feature = "deprecated-fields")] + assert_eq!(inv.role, Some(InvitationRole::Admin)); +} + +#[tokio::test] +async fn delete_invitation() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-1/invitations/inv-1")) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c.invitation_delete("org-1", "inv-1").await.unwrap(); + assert_eq!(resp.status, Some(200)); +} diff --git a/crates/clickhouse-cloud-api/tests/client/main.rs b/crates/clickhouse-cloud-api/tests/client/main.rs new file mode 100644 index 00000000..be260082 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/main.rs @@ -0,0 +1,52 @@ +mod activity; +mod api_keys; +mod backups; +mod byoc; +mod clickpipes; +mod clickstack; +mod client; +mod invitations; +mod members; +mod organizations; +mod postgres; +mod query_api_endpoints; +mod saved_queries; +mod services; +mod udfs; +mod whoami; + +use clickhouse_cloud_api::Client; +use wiremock::{MockServer, ResponseTemplate}; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +fn ok_json(result: serde_json::Value) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "requestId": "req-test", + "result": result + })) +} + +fn ok_empty() -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "requestId": "req-test" + })) +} + +fn created_json(result: serde_json::Value) -> ResponseTemplate { + ResponseTemplate::new(201).set_body_json(serde_json::json!({ + "status": 201, + "requestId": "req-test", + "result": result + })) +} + +async fn setup() -> (MockServer, Client) { + let s = MockServer::start().await; + let c = Client::with_base_url(s.uri(), "key", "secret"); + (s, c) +} diff --git a/crates/clickhouse-cloud-api/tests/client/members.rs b/crates/clickhouse-cloud-api/tests/client/members.rs new file mode 100644 index 00000000..718e25d6 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/members.rs @@ -0,0 +1,109 @@ +use clickhouse_cloud_api::{Client, models::*}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::{ok_empty, ok_json, setup}; + +// =========================================================================== +// Members +// =========================================================================== + +#[tokio::test] +async fn list_members() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/members")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": [ + { + "userId": "user-1", + "name": "Alice", + "email": "alice@example.com", + "role": "admin" + }, + { + "userId": "user-2", + "name": "Bob", + "email": "bob@example.com", + "role": "developer" + } + ] + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let resp = client.member_get_list("org-1").await.unwrap(); + let members = resp.result.unwrap(); + assert_eq!(members.len(), 2); + #[cfg(feature = "deprecated-fields")] + { + assert_eq!(members[0].role, Some(MemberRole::Admin)); + assert_eq!(members[1].role, Some(MemberRole::Developer)); + } +} + +#[tokio::test] +async fn get_member() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/members/user-1")) + .respond_with(ok_json(serde_json::json!({ + "userId": "user-1", + "name": "Alice", + "email": "alice@example.com", + "role": "admin" + }))) + .mount(&s) + .await; + + let resp = c.member_get("org-1", "user-1").await.unwrap(); + let member = resp.result.unwrap(); + assert_eq!(member.name.as_deref(), Some("Alice")); + #[cfg(feature = "deprecated-fields")] + assert_eq!(member.role, Some(MemberRole::Admin)); +} + +#[tokio::test] +async fn update_member() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/members/user-1")) + .respond_with(ok_json(serde_json::json!({ + "userId": "user-1", + "name": "Alice", + "email": "alice@example.com", + "role": "admin" + }))) + .mount(&s) + .await; + + let body = MemberPatchRequest { + #[cfg(feature = "deprecated-fields")] + role: Some(MemberPatchRequestRole::Admin), + ..Default::default() + }; + let resp = c.member_update("org-1", "user-1", &body).await.unwrap(); + let member = resp.result.unwrap(); + assert_eq!(member.email.as_deref(), Some("alice@example.com")); + #[cfg(feature = "deprecated-fields")] + assert_eq!(member.role, Some(MemberRole::Admin)); +} + +#[tokio::test] +async fn delete_member() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-1/members/user-1")) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c.member_delete("org-1", "user-1").await.unwrap(); + assert_eq!(resp.status, Some(200)); +} diff --git a/crates/clickhouse-cloud-api/tests/client/organizations.rs b/crates/clickhouse-cloud-api/tests/client/organizations.rs new file mode 100644 index 00000000..0605bacf --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/organizations.rs @@ -0,0 +1,661 @@ +use clickhouse_cloud_api::{Client, models::*}; +use wiremock::matchers::{basic_auth, bearer_token, body_partial_json, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::{ok_json, setup}; + +// =========================================================================== +// Organizations +// =========================================================================== + +#[tokio::test] +async fn list_organizations() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .and(basic_auth("test-key", "test-secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "requestId": "req-123", + "result": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Test Org" + } + ] + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "test-key", "test-secret"); + let resp = client.organization_get_list(None, None).await.unwrap(); + assert_eq!(resp.status, Some(200)); + let orgs = resp.result.unwrap(); + assert_eq!(orgs.len(), 1); + assert_eq!(orgs[0].name.as_deref(), Some("Test Org")); +} + +#[tokio::test] +async fn get_organization() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-123")) + .and(basic_auth("my-key", "my-secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "My Org", + "createdAt": "2024-01-01T00:00:00Z" + } + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "my-key", "my-secret"); + let resp = client.organization_get("org-123").await.unwrap(); + let org = resp.result.unwrap(); + assert_eq!(org.name.as_deref(), Some("My Org")); +} + +#[tokio::test] +async fn get_organization_preserves_snapshot_capability() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1")) + .respond_with(ok_json(serde_json::json!({ + "capabilities": {"snapshots": false} + }))) + .mount(&server) + .await; + let org = client + .organization_get("org-1") + .await + .unwrap() + .result + .unwrap(); + assert_eq!(org.capabilities.unwrap().snapshots, Some(false)); +} + +#[tokio::test] +async fn get_active_balances_with_pagination() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/activeBalances")) + .and(query_param("limit", "25")) + .and(query_param("offset", "50")) + .respond_with(ok_json(serde_json::json!({ + "totalRemainingPrepaidCredits": 12.5, + "prepaidBalances": [{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "remainingPrepaidCredits": 12.5, + "expirationDate": "2027-01-01T00:00:00Z" + }] + }))) + .mount(&s) + .await; + + let balances = c + .active_balances_get("org-1", Some(25), Some(50)) + .await + .unwrap() + .result + .unwrap(); + assert_eq!(balances.total_remaining_prepaid_credits, Some(12.5)); + assert_eq!(balances.prepaid_balances.unwrap().len(), 1); +} + +#[tokio::test] +async fn update_organization() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1")) + .and(body_partial_json( + serde_json::json!({"name": "Renamed Org"}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Renamed Org" + }))) + .mount(&s) + .await; + + let body = OrganizationPatchRequest { + name: Some("Renamed Org".to_string()), + ..Default::default() + }; + let resp = c.organization_update("org-1", &body).await.unwrap(); + let org = resp.result.unwrap(); + assert_eq!(org.name.as_deref(), Some("Renamed Org")); +} + +#[tokio::test] +async fn get_usage_cost_with_query_params() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/usageCost")) + .and(query_param("from_date", "2024-01-01")) + .and(query_param("to_date", "2024-01-31")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": { + "costs": [], + "grandTotalCHC": 50.25 + } + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let resp = client + .usage_cost_get("org-1", "2024-01-01", "2024-01-31", &[]) + .await + .unwrap(); + let cost = resp.result.unwrap(); + assert_eq!(cost.grand_total_chc, Some(50.25)); +} + +#[tokio::test] +async fn get_prometheus_metrics() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/prometheus")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("# HELP ch_metric A metric\nch_metric{service=\"svc-1\"} 42\n"), + ) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let resp = client + .organization_prometheus_get("org-1", None) + .await + .unwrap(); + assert!(resp.contains("ch_metric")); +} + +#[tokio::test] +async fn discover_organization_prometheus_targets() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/prometheus/discovery")) + .and(query_param("filtered_metrics", "false")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([{ + "targets": ["api.clickhouse.cloud"], + "labels": { + "__scheme__": "https", + "__metrics_path__": "/v1/organizations/org-1/services/svc-1/prometheus", + "__param_filtered_metrics": "false", + "clickhouse_org_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "clickhouse_service_id": "b1b2c3d4-e5f6-7890-abcd-ef1234567890", + "clickhouse_discovery_service_name": "analytics", + "__meta_clickhouse_region": "us-east-1", + "__meta_clickhouse_provider": "aws" + } + }])), + ) + .mount(&s) + .await; + + let groups = c + .organization_prometheus_discovery_get("org-1", Some("false")) + .await + .unwrap(); + assert_eq!(groups.len(), 1); + assert_eq!( + groups[0] + .labels + .as_ref() + .and_then(|labels| labels.scheme.as_deref()), + Some("https") + ); + let labels = groups[0].labels.as_ref().unwrap(); + assert_eq!(labels.meta_clickhouse_region.as_deref(), Some("us-east-1")); + assert_eq!(labels.meta_clickhouse_provider.as_deref(), Some("aws")); + assert_eq!(labels.additional_labels, None); +} + +#[tokio::test] +#[allow(deprecated)] +async fn get_private_endpoint_config() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/privateEndpointConfig")) + .and(query_param("cloud_provider", "aws")) + .and(query_param("region_id", "us-east-1")) + .respond_with(ok_json(serde_json::json!({ + "endpointServiceId": "com.amazonaws.vpce.us-east-1.vpce-svc-abc" + }))) + .mount(&s) + .await; + + let resp = c + .organization_private_endpoint_config_get_list("org-1", "aws", "us-east-1") + .await + .unwrap(); + let config = resp.result.unwrap(); + assert_eq!( + config.endpoint_service_id.as_deref(), + Some("com.amazonaws.vpce.us-east-1.vpce-svc-abc") + ); +} + +#[tokio::test] +async fn list_organizations_pagination_encodes_parameters_and_preserves_envelope() { + let (server, client) = setup().await; + let cursor = "opaque +/=&?雪"; + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .and(basic_auth("key", "secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200.0, "requestId": "req-page", "result": [], + "limit": 25, "nextCursor": cursor + }))) + .expect(4) + .mount(&server) + .await; + for (limit, cursor_arg) in [ + (None, None), + (Some(25), None), + (None, Some(cursor)), + (Some(25), Some(cursor)), + ] { + let response = client + .organization_get_list(limit, cursor_arg) + .await + .unwrap(); + assert_eq!(response.status, Some(200)); + assert_eq!(response.request_id.as_deref(), Some("req-page")); + assert_eq!(response.result, Some(vec![])); + assert_eq!(response.error, None); + assert_eq!(response.limit, Some(25)); + assert_eq!(response.next_cursor.as_deref(), Some(cursor)); + } + let requests = server.received_requests().await.unwrap(); + let queries: Vec> = requests + .iter() + .map(|request| request.url.query_pairs().into_owned().collect()) + .collect(); + assert_eq!( + queries, + vec![ + vec![], + vec![("limit".into(), "25".into())], + vec![("cursor".into(), cursor.into())], + vec![ + ("limit".into(), "25".into()), + ("cursor".into(), cursor.into()) + ], + ] + ); +} + +// =========================================================================== +// Organization quotas +// =========================================================================== + +#[tokio::test] +async fn list_quotas() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/quotas")) + .respond_with(ok_json(serde_json::json!([ + { + "quotaCode": "services-per-organization", + "name": "Services per organization", + "description": "Limits services.", + "scope": "organization", + "value": 20, + "usage": 3, + "adjustable": true + } + ]))) + .mount(&s) + .await; + + let resp = c.organization_quotas_get_list("org-1").await.unwrap(); + let quotas = resp.result.unwrap(); + assert_eq!(quotas.len(), 1); + assert_eq!( + quotas[0].quota_code, + Some(OrganizationQuotaQuotacode::Services_per_organization) + ); + assert_eq!(quotas[0].scope, Some(OrganizationQuotaScope::Organization)); + assert_eq!(quotas[0].usage, Some(3)); +} + +#[tokio::test] +async fn get_quota() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/quotas/replicas-per-warehouse", + )) + .respond_with(ok_json(serde_json::json!({ + "quotaCode": "replicas-per-warehouse", + "name": "Replicas per warehouse", + "description": "Limits each warehouse individually.", + "scope": "warehouse", + "value": 20, + "adjustable": true + }))) + .mount(&s) + .await; + + let resp = c + .organization_quota_get("org-1", "replicas-per-warehouse") + .await + .unwrap(); + let quota = resp.result.unwrap(); + assert_eq!( + quota.quota_code, + Some(OrganizationQuotaQuotacode::Replicas_per_warehouse) + ); + assert_eq!(quota.scope, Some(OrganizationQuotaScope::Warehouse)); + assert_eq!(quota.usage, None); +} + +#[tokio::test] +async fn list_organizations_returns_empty_vec() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + let resp = c.organization_get_list(None, None).await.unwrap(); + let orgs = resp.result.unwrap(); + assert!(orgs.is_empty()); +} + +#[tokio::test] +async fn usage_cost_with_filters() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/usageCost")) + .and(query_param("from_date", "2024-01-01")) + .and(query_param("to_date", "2024-01-31")) + .and(query_param("filter", "service_id=svc-1")) + .respond_with(ok_json(serde_json::json!({ + "costs": [], + "grandTotalCHC": 10.0 + }))) + .mount(&s) + .await; + + let resp = c + .usage_cost_get("org-1", "2024-01-01", "2024-01-31", &["service_id=svc-1"]) + .await + .unwrap(); + let cost = resp.result.unwrap(); + assert_eq!(cost.grand_total_chc, Some(10.0)); +} + +#[tokio::test] +async fn organization_prometheus_with_filtered_metrics() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/prometheus")) + .and(query_param("filtered_metrics", "cpu,memory")) + .respond_with(ResponseTemplate::new(200).set_body_string("cpu 42\nmemory 1024\n")) + .mount(&s) + .await; + + let resp = c + .organization_prometheus_get("org-1", Some("cpu,memory")) + .await + .unwrap(); + assert!(resp.contains("cpu 42")); +} + +#[tokio::test] +async fn organization_prometheus_without_filter() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/prometheus")) + .respond_with(ResponseTemplate::new(200).set_body_string("# HELP metric\nmetric 1\n")) + .mount(&s) + .await; + + let resp = c.organization_prometheus_get("org-1", None).await.unwrap(); + assert!(resp.contains("metric")); +} + +#[tokio::test] +async fn credit_balances_get_includes_trial_and_prepaid_balances() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org/creditBalances")) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!({ + "totalRemainingCredits": 12.5, + "balances": [{"type": "trial", "remainingCredits": 2.5}, {"type": "prepaid", "remainingCredits": 10.0}] + }))) + .expect(1).mount(&server).await; + let result = client + .credit_balances_get("org") + .await + .unwrap() + .result + .unwrap(); + assert_eq!(result.total_remaining_credits, Some(12.5)); + let balances = result.balances.unwrap(); + assert_eq!(balances[0].r#type, Some(CreditBalanceType::Trial)); + assert_eq!(balances[1].r#type, Some(CreditBalanceType::Prepaid)); +} + +// --------------------------------------------------------------------------- +// Notification types +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn list_notification_types() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/notificationTypes")) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!([ + { + "name": "service_backup_failed", + "title": "Backup failed", + "description": "A scheduled backup did not complete.", + "category": "service", + "severity": "warning", + "resourceType": "service", + "isConfigurable": true, + "isAvailable": true + }, + { + "name": "billing_new_tier", + "title": "New tier", + "category": "billing", + "severity": "page_someone", + "resourceType": "organization", + "isConfigurable": false, + "isAvailable": false + } + ]))) + .expect(1) + .mount(&s) + .await; + + let resp = c.notification_types_get_list("org-1").await.unwrap(); + let types = resp.result.unwrap(); + assert_eq!(types.len(), 2); + assert_eq!(types[0].name.as_deref(), Some("service_backup_failed")); + assert_eq!(types[0].title.as_deref(), Some("Backup failed")); + assert_eq!( + types[0].description.as_deref(), + Some("A scheduled backup did not complete.") + ); + assert_eq!(types[0].category.as_deref(), Some("service")); + assert_eq!(types[0].severity, Some(NotificationTypeSeverity::Warning)); + assert_eq!(types[0].resource_type.as_deref(), Some("service")); + assert_eq!(types[0].is_configurable, Some(true)); + assert_eq!(types[0].is_available, Some(true)); + assert_eq!(types[1].description, None); + assert_eq!( + types[1].severity, + Some(NotificationTypeSeverity::Unknown( + "page_someone".to_string() + )) + ); + assert_eq!(types[1].is_available, Some(false)); +} + +#[tokio::test] +async fn notification_types_get_list_surfaces_api_errors() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/notificationTypes")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "status": 400, + "error": "Invalid organization" + }))) + .mount(&s) + .await; + + let err = c.notification_types_get_list("org-1").await.unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 400); + assert_eq!(message, "Invalid organization"); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[tokio::test] +async fn notification_get_list_preserves_filters_cursor_and_envelope() { + let (server, client) = setup().await; + let cursor = "opaque +/=&?é"; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/notifications")) + .and(basic_auth("key", "secret")) + .and(query_param("limit", "25")) + .and(query_param("cursor", cursor)) + .and(query_param("severity", "warning")) + .and(query_param("type", "service_backup_failed")) + .and(query_param("resource_id", "service-1")) + .and(query_param("resource_type", "service")) + .and(query_param("from_date", "2026-10-01T00:00:00+02:00")) + .and(query_param("to_date", "2026-10-08T00:00:00Z")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, "requestId": "notification-page", "limit": 25, "nextCursor": "", + "result": [{ + "id": "1b7e0a94-2d0f-4c3a-8f1e-6c9b2d5a4e7f", + "type": "service_backup_failed", "severity": "warning", "resourceType": "service", + "resourceId": "service-1", "resourceName": "analytics", "createdAt": "2026-10-07T09:30:00Z", + "title": "Backup failed", "description": "Review backup settings.", "future": 42 + }] + }))) + .expect(1) + .mount(&server) + .await; + let response = client + .notification_get_list( + "org-1", + Some(25), + Some(cursor), + Some("warning"), + Some("service_backup_failed"), + Some("service-1"), + Some("service"), + Some("2026-10-01T00:00:00+02:00"), + Some("2026-10-08T00:00:00Z"), + ) + .await + .unwrap(); + assert_eq!(response.request_id.as_deref(), Some("notification-page")); + assert_eq!(response.limit, Some(25)); + assert_eq!(response.next_cursor.as_deref(), Some("")); + let notifications = response.result.unwrap(); + assert_eq!(notifications.len(), 1); + assert_eq!(notifications[0].title.as_deref(), Some("Backup failed")); + assert_eq!(notifications[0].resource_name.as_deref(), Some("analytics")); + assert!(notifications[0].created_at.is_some()); +} + +#[tokio::test] +async fn notification_get_list_tolerates_sparse_null_and_empty_pages() { + for body in [ + serde_json::json!({"result": [{}, {"id": null, "createdAt": null}], "nextCursor": null}), + serde_json::json!({"result": []}), + serde_json::json!({"result": null}), + serde_json::json!({}), + ] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/notifications")) + .and(bearer_token("token")) + .respond_with(ResponseTemplate::new(200).set_body_json(body.clone())) + .expect(1) + .mount(&server) + .await; + let client = Client::with_bearer_token(server.uri(), "token"); + let response = client + .notification_get_list("org-1", None, None, None, None, None, None, None, None) + .await + .unwrap(); + assert_eq!(response.next_cursor, None); + if body["result"].is_array() { + assert_eq!( + response.result.as_ref().unwrap().len(), + body["result"].as_array().unwrap().len() + ); + } else { + assert_eq!(response.result, None); + } + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests[0].url.query(), None); + } +} + +#[tokio::test] +async fn notification_get_list_surfaces_api_errors() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/notifications")) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(serde_json::json!({"error": "invalid cursor"})), + ) + .mount(&server) + .await; + let error = client + .notification_get_list( + "org-1", + None, + Some("bad"), + None, + None, + None, + None, + None, + None, + ) + .await + .unwrap_err(); + assert!( + matches!(error, clickhouse_cloud_api::Error::Api { status: 400, message } if message == "invalid cursor") + ); +} diff --git a/crates/clickhouse-cloud-api/tests/client/postgres.rs b/crates/clickhouse-cloud-api/tests/client/postgres.rs new file mode 100644 index 00000000..cb0fcfb0 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/postgres.rs @@ -0,0 +1,648 @@ +use chrono::Utc; +use clickhouse_cloud_api::models::*; +use wiremock::matchers::{basic_auth, body_partial_json, method, path, query_param}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::{ok_empty, ok_json, setup}; + +// =========================================================================== +// PostgreSQL Services +// =========================================================================== + +#[tokio::test] +async fn list_postgres_logs_with_filters() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/postgres/pg-1/logs")) + .and(query_param("from_date", "2026-08-01T00:00:00Z")) + .and(query_param("to_date", "2026-08-02T00:00:00Z")) + .and(query_param("body_contains", "checkpoint")) + .and(query_param("severity", "LOG")) + .and(query_param("sort_order", "asc")) + .and(query_param("limit", "100")) + .and(query_param("offset", "20")) + .respond_with(ok_json(serde_json::json!([{ + "timestamp": "2026-08-01T12:00:00Z", + "severity": "LOG", + "body": "checkpoint complete" + }]))) + .mount(&s) + .await; + + let logs = c + .postgres_logs_get_list( + "org-1", + "pg-1", + "2026-08-01T00:00:00Z", + "2026-08-02T00:00:00Z", + Some("checkpoint"), + Some("LOG"), + Some(&PostgresLogsGetListSortorder::Asc), + Some(100), + Some(20), + ) + .await + .unwrap() + .result + .unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].body.as_deref(), Some("checkpoint complete")); +} + +#[tokio::test] +async fn list_slow_query_patterns_with_typed_sorting() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/postgres/pg-1/slowQueryPatterns", + )) + .and(query_param("from_date", "2026-08-01T00:00:00Z")) + .and(query_param("to_date", "2026-08-02T00:00:00Z")) + .and(query_param("db_name", "analytics")) + .and(query_param("db_user", "reporter")) + .and(query_param("db_operation", "SELECT")) + .and(query_param("app", "dashboard")) + .and(query_param("sort_by", "p95_duration")) + .and(query_param("sort_order", "asc")) + .and(query_param("limit", "100")) + .and(query_param("offset", "20")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + let patterns = c + .slow_query_patterns_get_list( + "org-1", + "pg-1", + "2026-08-01T00:00:00Z", + "2026-08-02T00:00:00Z", + Some("analytics"), + Some("reporter"), + Some("SELECT"), + Some("dashboard"), + Some(&SlowQueryPatternsGetListSortby::P95_duration), + Some(&SlowQueryPatternsGetListSortorder::Asc), + Some(100), + Some(20), + ) + .await + .unwrap() + .result + .unwrap(); + assert!(patterns.is_empty()); +} + +#[tokio::test] +async fn create_postgres_service() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/postgres")) + .and(body_partial_json(serde_json::json!({"name": "pg-svc", "provider": "aws", "region": "us-east-1", "size": "c6gd.large", "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"}}))) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "pg-svc", + "state": "creating", + "provider": "aws", + "region": "us-east-1", + "password": "generated-pw" + }))) + .mount(&s) + .await; + + let body = PostgresServicePostRequest { + name: "pg-svc".to_string(), + provider: PgProvider::Aws, + region: "us-east-1".to_string(), + size: PgSize::C6gd_large, + pg_bouncer_config: Some(PgBouncerConfig::from([ + ("default_pool_size".into(), "16".into()), + ("future_parameter".into(), "on".into()), + ])), + ..Default::default() + }; + let resp = c.postgres_service_create("org-1", &body).await.unwrap(); + let pg = resp.result.unwrap(); + assert_eq!(pg.name.as_deref(), Some("pg-svc")); + assert_eq!(pg.password.as_deref(), Some("generated-pw")); +} + +#[tokio::test] +async fn list_postgres_services() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/postgres")) + .respond_with(ok_json(serde_json::json!([ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "pg-1", + "state": "running" + } + ]))) + .mount(&s) + .await; + + let resp = c.postgres_service_get_list("org-1").await.unwrap(); + let services = resp.result.unwrap(); + assert_eq!(services.len(), 1); + assert_eq!(services[0].name.as_deref(), Some("pg-1")); +} + +#[tokio::test] +async fn get_postgres_service() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/postgres/pg-1")) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "pg-1", + "state": "running", + "connectionString": "postgres://user@host/db" + }))) + .mount(&s) + .await; + + let resp = c.postgres_service_get("org-1", "pg-1").await.unwrap(); + let pg = resp.result.unwrap(); + assert_eq!(pg.name.as_deref(), Some("pg-1")); + assert_eq!( + pg.connection_string.as_deref(), + Some("postgres://user@host/db") + ); +} + +#[tokio::test] +async fn update_postgres_service() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/postgres/pg-1")) + .and(body_partial_json(serde_json::json!({"size": "c6gd.large"}))) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "pg-1" + }))) + .mount(&s) + .await; + + let body = PostgresServicePatchRequest { + size: Some(PgSize::C6gd_large), + ..Default::default() + }; + let resp = c + .postgres_service_patch("org-1", "pg-1", &body) + .await + .unwrap(); + let pg = resp.result.unwrap(); + assert_eq!(pg.name.as_deref(), Some("pg-1")); +} + +#[tokio::test] +async fn delete_postgres_service() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-1/postgres/pg-1")) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c.postgres_service_delete("org-1", "pg-1").await.unwrap(); + assert_eq!(resp.status, Some(200)); +} + +#[tokio::test] +async fn update_postgres_service_state() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/postgres/pg-1/state")) + .and(body_partial_json(serde_json::json!({"command": "restart"}))) + .respond_with(ok_json(serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "pg-1", + "state": "restarting" + }))) + .mount(&s) + .await; + + let body = PostgresServiceSetState { + command: PostgresServiceSetStateCommand::Restart, + }; + let resp = c + .postgres_service_patch_state("org-1", "pg-1", &body) + .await + .unwrap(); + let pg = resp.result.unwrap(); + assert_eq!(pg.name.as_deref(), Some("pg-1")); +} + +#[tokio::test] +async fn set_postgres_password() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/postgres/pg-1/password")) + .and(body_partial_json( + serde_json::json!({"password": "new-pg-password"}), + )) + .respond_with(ok_json(serde_json::json!({ + "password": "new-pg-password" + }))) + .mount(&s) + .await; + + let body = PostgresServiceSetPassword { + password: "new-pg-password".to_string(), + }; + let resp = c + .postgres_service_set_password("org-1", "pg-1", &body) + .await + .unwrap(); + let result = resp.result.unwrap(); + assert_eq!(result.password.as_deref(), Some("new-pg-password")); +} + +#[tokio::test] +async fn get_postgres_certs() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/postgres/pg-1/caCertificates")) + .respond_with( + ResponseTemplate::new(200).set_body_string( + "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----\n", + ), + ) + .mount(&s) + .await; + + let resp = c.postgres_service_certs_get("org-1", "pg-1").await.unwrap(); + assert!(resp.contains("BEGIN CERTIFICATE")); +} + +#[tokio::test] +async fn get_postgres_config() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/postgres/pg-1/config")) + .respond_with(ok_json(serde_json::json!({ + "pgConfig": { + "max_connections": 100 + }, + "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"} + }))) + .mount(&s) + .await; + + let resp = c + .postgres_instance_config_get("org-1", "pg-1") + .await + .unwrap(); + let config = resp.result.unwrap(); + assert_eq!( + config.pg_bouncer_config.as_ref().unwrap()["default_pool_size"], + "16" + ); + assert_eq!( + config.pg_bouncer_config.as_ref().unwrap()["future_parameter"], + "on" + ); + assert_eq!( + config + .pg_config + .expect("pgConfig in response") + .max_connections, + Some(serde_json::json!(100)) + ); +} + +#[tokio::test] +async fn replace_postgres_config() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/postgres/pg-1/config")) + .and(body_partial_json( + serde_json::json!({"pgConfig": {"max_connections": 200}, "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"}}), + )) + .respond_with(ok_json(serde_json::json!({ + "message": "Configuration updated", + "pgConfig": { "max_connections": 200 }, + "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"} + }))) + .mount(&s) + .await; + + let body = PostgresInstanceConfig { + pg_config: PgConfig { + max_connections: Some(serde_json::json!(200)), + ..Default::default() + }, + pg_bouncer_config: PgBouncerConfig::from([ + ("default_pool_size".into(), "16".into()), + ("future_parameter".into(), "on".into()), + ]), + }; + let resp = c + .postgres_instance_config_post("org-1", "pg-1", &body) + .await + .unwrap(); + let result = resp.result.unwrap(); + assert_eq!( + result.pg_bouncer_config.as_ref().unwrap()["default_pool_size"], + "16" + ); + assert_eq!( + result.pg_bouncer_config.as_ref().unwrap()["future_parameter"], + "on" + ); + assert_eq!(result.message, Some("Configuration updated".to_string())); + assert_eq!( + result + .pg_config + .expect("pgConfig in response") + .max_connections, + Some(serde_json::json!(200)) + ); +} + +#[tokio::test] +async fn patch_postgres_config() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/postgres/pg-1/config")) + .and(body_partial_json( + serde_json::json!({"pgConfig": {"max_connections": 150}, "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"}}), + )) + .respond_with(ok_json(serde_json::json!({ + "message": "OK", + "pgConfig": { "max_connections": 150 }, + "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"} + }))) + .mount(&s) + .await; + + let body = PostgresInstanceConfig { + pg_config: PgConfig { + max_connections: Some(serde_json::json!(150)), + ..Default::default() + }, + pg_bouncer_config: PgBouncerConfig::from([ + ("default_pool_size".into(), "16".into()), + ("future_parameter".into(), "on".into()), + ]), + }; + let resp = c + .postgres_instance_config_patch("org-1", "pg-1", &body) + .await + .unwrap(); + let result = resp.result.unwrap(); + assert_eq!( + result.pg_bouncer_config.as_ref().unwrap()["default_pool_size"], + "16" + ); + assert_eq!( + result.pg_bouncer_config.as_ref().unwrap()["future_parameter"], + "on" + ); + assert_eq!( + result + .pg_config + .expect("pgConfig in response") + .max_connections, + Some(serde_json::json!(150)) + ); +} + +#[tokio::test] +async fn create_postgres_read_replica() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/postgres/pg-1/readReplica")) + .and(body_partial_json( + serde_json::json!({"name": "pg-1-replica", "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"}}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + "name": "pg-1-replica", + "isPrimary": false + }))) + .mount(&s) + .await; + + let body = PostgresServiceReadReplicaRequest { + name: "pg-1-replica".to_string(), + pg_bouncer_config: Some(PgBouncerConfig::from([ + ("default_pool_size".into(), "16".into()), + ("future_parameter".into(), "on".into()), + ])), + ..Default::default() + }; + let resp = c + .postgres_instance_create_read_replica("org-1", "pg-1", &body) + .await + .unwrap(); + let pg = resp.result.unwrap(); + assert_eq!(pg.is_primary, Some(false)); +} + +#[tokio::test] +async fn restore_postgres_service() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/postgres/pg-1/restoredService", + )) + .and(body_partial_json( + serde_json::json!({"name": "pg-1-restored", "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"}}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "cccccccc-dddd-eeee-ffff-000000000000", + "name": "pg-1-restored", + "state": "creating" + }))) + .mount(&s) + .await; + + let body = PostgresServiceRestoreRequest { + name: "pg-1-restored".to_string(), + pg_bouncer_config: Some(PgBouncerConfig::from([ + ("default_pool_size".into(), "16".into()), + ("future_parameter".into(), "on".into()), + ])), + restore_target: Utc::now(), + ..Default::default() + }; + let resp = c + .postgres_instance_restore("org-1", "pg-1", &body) + .await + .unwrap(); + let pg = resp.result.unwrap(); + assert_eq!(pg.name.as_deref(), Some("pg-1-restored")); +} + +#[tokio::test] +async fn postgres_instance_prometheus_get_returns_metrics() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/postgres/pg-1/prometheus")) + .respond_with(ResponseTemplate::new(200).set_body_string("# HELP pg_metric\npg_metric 7\n")) + .mount(&s) + .await; + + let resp = c + .postgres_instance_prometheus_get("org-1", "pg-1") + .await + .unwrap(); + assert!(resp.contains("pg_metric")); +} + +#[tokio::test] +async fn postgres_org_prometheus_get_returns_metrics() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/postgres/prometheus")) + .respond_with( + ResponseTemplate::new(200).set_body_string("# HELP pg_org_metric\npg_org_metric 3\n"), + ) + .mount(&s) + .await; + + let resp = c.postgres_org_prometheus_get("org-1").await.unwrap(); + assert!(resp.contains("pg_org_metric")); +} + +// =========================================================================== +// Postgres service backups +// =========================================================================== + +#[tokio::test] +async fn postgres_service_backup_get_list_encodes_cursor_and_reads_envelope_pagination() { + for (cursor, limit) in [ + (None, None), + (Some("next+/=&? page"), None), + (None, Some(25)), + (Some("next+/=&? page"), Some(25)), + ] { + let (server, client) = setup().await; + let backups = serde_json::json!([ + { + "key": "basebackups_005/000000010000000000000002_backup_stop_sentinel.json", + "lastModified": "2026-03-31T18:17:37Z" + } + ]); + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/postgres/pg-1/backups")) + .and(basic_auth("key", "secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "requestId": "req-test", + "result": backups, + "limit": 25, + "totalCount": 2, + "nextCursor": "another-page" + }))) + .expect(1) + .mount(&server) + .await; + let response = client + .postgres_service_backup_get_list("org-1", "pg-1", cursor, limit) + .await + .unwrap(); + assert_eq!(response.limit, Some(25)); + assert_eq!(response.total_count, Some(2)); + assert_eq!(response.next_cursor.as_deref(), Some("another-page")); + let result = response.result.unwrap(); + assert_eq!( + result[0].key.as_deref(), + Some("basebackups_005/000000010000000000000002_backup_stop_sentinel.json") + ); + assert_eq!( + result[0].last_modified, + Some( + chrono::DateTime::parse_from_rfc3339("2026-03-31T18:17:37Z") + .unwrap() + .with_timezone(&Utc) + ) + ); + let requests = server.received_requests().await.unwrap(); + let query: std::collections::HashMap<_, _> = + requests[0].url.query_pairs().into_owned().collect(); + let mut expected = std::collections::HashMap::new(); + if let Some(cursor) = cursor { + expected.insert("cursor".to_owned(), cursor.to_owned()); + } + if let Some(limit) = limit { + expected.insert("limit".to_owned(), limit.to_string()); + } + assert_eq!(query, expected); + if cursor.is_none() && limit.is_none() { + assert!(requests[0].url.query().is_none()); + } + assert!(requests[0].body.is_empty()); + } +} + +#[tokio::test] +async fn postgres_service_backup_get_list_last_page_has_null_next_cursor() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org/postgres/pg/backups")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "requestId": "req-test", + "result": [], + "limit": 100, + "totalCount": 0, + "nextCursor": null + }))) + .expect(1) + .mount(&server) + .await; + let response = client + .postgres_service_backup_get_list("org", "pg", None, None) + .await + .unwrap(); + assert_eq!(response.result, Some(vec![])); + assert_eq!(response.next_cursor, None); + assert_eq!(response.total_count, Some(0)); +} + +#[tokio::test] +async fn postgres_service_backup_get_list_propagates_api_errors() { + for (status, body, expected_message) in [ + ( + 404, + serde_json::json!({"status": 404, "error": "Postgres service not found"}).to_string(), + "Postgres service not found", + ), + (500, "upstream failed".to_owned(), "upstream failed"), + ] { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org/postgres/pg/backups")) + .respond_with(ResponseTemplate::new(status).set_body_string(body)) + .expect(1) + .mount(&server) + .await; + let error = client + .postgres_service_backup_get_list("org", "pg", None, None) + .await + .unwrap_err(); + assert!( + matches!(error, clickhouse_cloud_api::Error::Api { status: actual_status, ref message } if actual_status == status && message == expected_message), + "unexpected error: {error:?}" + ); + } +} diff --git a/crates/clickhouse-cloud-api/tests/client/query_api_endpoints.rs b/crates/clickhouse-cloud-api/tests/client/query_api_endpoints.rs new file mode 100644 index 00000000..b322ce62 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/query_api_endpoints.rs @@ -0,0 +1,243 @@ +use clickhouse_cloud_api::{Client, models::*}; +use wiremock::matchers::{basic_auth, bearer_token, body_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::{created_json, ok_empty, ok_json, setup}; + +#[tokio::test] +async fn query_api_endpoint_create_and_update_send_complete_requests() { + let (server, client) = setup().await; + let collection = "/v1/organizations/org-1/services/svc-1/query-api-endpoints"; + let minimal = serde_json::json!({ + "name": "daily total", "sql": "SELECT count() FROM events", "database": "default", + "apiKeyIds": ["00000000-0000-4000-8000-000000000001"], "roles": ["reader"] + }); + let maximal = serde_json::json!({ + "name": "filtered total", "sql": "SELECT count() FROM events WHERE kind = {kind:String}", + "database": "analytics", "apiKeyIds": ["00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002"], "roles": ["reader"], + "parameters": {"kind": "page view"}, "allowedOrigins": ["https://example.com"] + }); + for body in [minimal, maximal] { + let request: PublicQueryApiEndpointRequest = serde_json::from_value(body.clone()).unwrap(); + let mut returned = body.clone(); + returned["id"] = serde_json::json!("00000000-0000-4000-8000-000000000003"); + returned["url"] = serde_json::json!("https://queries.clickhouse.cloud/run/endpoint-1"); + returned["ownerType"] = serde_json::json!("queryApiEndpoint"); + Mock::given(method("POST")) + .and(path(collection)) + .and(basic_auth("key", "secret")) + .and(body_json(body.clone())) + .respond_with(created_json(returned.clone())) + .expect(1) + .mount(&server) + .await; + let response = client + .query_api_endpoint_create("org-1", "svc-1", &request) + .await + .unwrap(); + assert_eq!(response.status, Some(201)); + assert_eq!( + serde_json::to_value(response.result.unwrap()).unwrap(), + returned + ); + + Mock::given(method("PUT")) + .and(path(format!("{collection}/endpoint-1"))) + .and(basic_auth("key", "secret")) + .and(body_json(body)) + .respond_with(ok_json(returned.clone())) + .expect(1) + .mount(&server) + .await; + let response = client + .query_api_endpoint_update("org-1", "svc-1", "endpoint-1", &request) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(response.result.unwrap()).unwrap(), + returned + ); + } +} + +#[tokio::test] +async fn query_api_endpoint_get_and_delete_use_endpoint_path() { + let (server, client) = setup().await; + let endpoint_path = "/v1/organizations/org-1/services/svc-1/query-api-endpoints/endpoint-1"; + Mock::given(method("GET")) + .and(path(endpoint_path)) + .and(basic_auth("key", "secret")) + .respond_with(ok_json( + serde_json::json!({"id": "00000000-0000-4000-8000-000000000003", "name": "example"}), + )) + .expect(1) + .mount(&server) + .await; + let response = client + .query_api_endpoint_get("org-1", "svc-1", "endpoint-1") + .await + .unwrap(); + assert_eq!( + response.result.unwrap().id, + Some(uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000003").unwrap()) + ); + Mock::given(method("DELETE")) + .and(path(endpoint_path)) + .and(basic_auth("key", "secret")) + .respond_with(ok_empty()) + .expect(1) + .mount(&server) + .await; + let response = client + .query_api_endpoint_delete("org-1", "svc-1", "endpoint-1") + .await + .unwrap(); + assert_eq!(response.status, Some(200)); + assert_eq!(response.request_id.as_deref(), Some("req-test")); + assert!(response.result.is_none()); + for request in server.received_requests().await.unwrap() { + assert!(request.body.is_empty()); + assert!(request.url.query().is_none()); + } +} + +#[tokio::test] +async fn query_api_endpoint_list_encodes_cursor_and_omits_absent_parameters() { + for (cursor, limit) in [ + (None, None), + (Some("next+/=&? page"), None), + (None, Some(25)), + (Some("next+/=&? page"), Some(25)), + ] { + let (server, client) = setup().await; + let result = serde_json::json!([ + {"id": "00000000-0000-4000-8000-000000000003", "name": "example", "ownerType": "user"} + ]); + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/query-api-endpoints", + )) + .and(basic_auth("key", "secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "result": result, "limit": 25, "totalCount": 42, + "nextCursor": "another-page", "status": 200 + }))) + .expect(1) + .mount(&server) + .await; + let response = client + .query_api_endpoint_list("org-1", "svc-1", cursor, limit) + .await + .unwrap(); + assert_eq!(response.limit, Some(25)); + assert_eq!(response.total_count, Some(42)); + assert_eq!(response.next_cursor.as_deref(), Some("another-page")); + assert_eq!( + serde_json::to_value(response.result.unwrap()).unwrap(), + result + ); + let requests = server.received_requests().await.unwrap(); + let query: std::collections::HashMap<_, _> = + requests[0].url.query_pairs().into_owned().collect(); + let mut expected = std::collections::HashMap::new(); + if let Some(cursor) = cursor { + expected.insert("cursor".to_owned(), cursor.to_owned()); + } + if let Some(limit) = limit { + expected.insert("limit".to_owned(), limit.to_string()); + } + assert_eq!(query, expected); + if cursor.is_none() && limit.is_none() { + assert!(requests[0].url.query().is_none()); + } + assert!(requests[0].body.is_empty()); + } +} + +#[tokio::test] +async fn query_api_endpoint_reads_use_bearer_auth() { + let server = MockServer::start().await; + let client = Client::with_bearer_token(server.uri(), "token"); + for (endpoint_path, result) in [ + ( + "/v1/organizations/org/services/svc/query-api-endpoints", + serde_json::json!([]), + ), + ( + "/v1/organizations/org/services/svc/query-api-endpoints/endpoint", + serde_json::json!({}), + ), + ] { + Mock::given(method("GET")) + .and(path(endpoint_path)) + .and(bearer_token("token")) + .respond_with(ok_json(result)) + .expect(1) + .mount(&server) + .await; + } + client + .query_api_endpoint_list("org", "svc", None, None) + .await + .unwrap(); + client + .query_api_endpoint_get("org", "svc", "endpoint") + .await + .unwrap(); +} + +#[tokio::test] +async fn query_api_endpoint_methods_propagate_api_errors() { + for (status, body, expected_message) in [ + ( + 403, + serde_json::json!({"status": 403, "error": "forbidden"}).to_string(), + "forbidden", + ), + ( + 409, + serde_json::json!({"status": 409, "error": "user-owned endpoint"}).to_string(), + "user-owned endpoint", + ), + (500, "upstream failed".to_owned(), "upstream failed"), + ] { + let (server, client) = setup().await; + Mock::given(basic_auth("key", "secret")) + .respond_with(ResponseTemplate::new(status).set_body_string(body)) + .expect(5) + .mount(&server) + .await; + let request: PublicQueryApiEndpointRequest = serde_json::from_value(serde_json::json!({ + "name": "example", "sql": "SELECT 1", "database": "default", + "apiKeyIds": ["00000000-0000-4000-8000-000000000001"], "roles": ["reader"] + })) + .unwrap(); + let errors = [ + client + .query_api_endpoint_create("org", "svc", &request) + .await + .unwrap_err(), + client + .query_api_endpoint_get("org", "svc", "endpoint") + .await + .unwrap_err(), + client + .query_api_endpoint_list("org", "svc", None, None) + .await + .unwrap_err(), + client + .query_api_endpoint_update("org", "svc", "endpoint", &request) + .await + .unwrap_err(), + client + .query_api_endpoint_delete("org", "svc", "endpoint") + .await + .unwrap_err(), + ]; + for error in errors { + assert!( + matches!(error, clickhouse_cloud_api::Error::Api { status: actual_status, message } if actual_status == status && message == expected_message) + ); + } + } +} diff --git a/crates/clickhouse-cloud-api/tests/client/saved_queries.rs b/crates/clickhouse-cloud-api/tests/client/saved_queries.rs new file mode 100644 index 00000000..3363749d --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/saved_queries.rs @@ -0,0 +1,286 @@ +use clickhouse_cloud_api::{Client, models::*}; +use wiremock::matchers::{basic_auth, bearer_token, body_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::{created_json, ok_empty, ok_json, setup}; + +#[tokio::test] +async fn saved_query_create_and_update_send_complete_requests() { + let (server, client) = setup().await; + let collection = "/v1/organizations/org-1/services/svc-1/saved-queries"; + let minimal = serde_json::json!({ + "name": "daily total", "sql": "SELECT count() FROM events", "database": "default" + }); + let maximal = serde_json::json!({ + "name": "filtered total", "sql": "SELECT count() FROM events WHERE kind = {kind:String}", + "database": "analytics", "parameters": {"kind": "page view"} + }); + for body in [minimal, maximal] { + let request: PublicSavedQueryRequest = serde_json::from_value(body.clone()).unwrap(); + let mut returned = body.clone(); + returned["id"] = serde_json::json!("00000000-0000-4000-8000-000000000003"); + if returned.get("parameters").is_none() { + returned["parameters"] = serde_json::json!({}); + } + Mock::given(method("POST")) + .and(path(collection)) + .and(basic_auth("key", "secret")) + .and(body_json(body.clone())) + .respond_with(created_json(returned.clone())) + .expect(1) + .mount(&server) + .await; + let response = client + .saved_query_create("org-1", "svc-1", &request) + .await + .unwrap(); + assert_eq!(response.status, Some(201)); + assert_eq!( + serde_json::to_value(response.result.unwrap()).unwrap(), + returned + ); + + Mock::given(method("PUT")) + .and(path(format!("{collection}/query-1"))) + .and(basic_auth("key", "secret")) + .and(body_json(body)) + .respond_with(ok_json(returned.clone())) + .expect(1) + .mount(&server) + .await; + let response = client + .saved_query_update("org-1", "svc-1", "query-1", &request) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(response.result.unwrap()).unwrap(), + returned + ); + } + for request in server.received_requests().await.unwrap() { + assert!(request.url.query().is_none()); + } +} + +#[tokio::test] +async fn saved_query_get_and_delete_use_query_path() { + let (server, client) = setup().await; + let query_path = "/v1/organizations/org-1/services/svc-1/saved-queries/query-1"; + Mock::given(method("GET")) + .and(path(query_path)) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!({ + "id": "00000000-0000-4000-8000-000000000003", + "name": "example", + "sql": "SELECT {n:UInt8}", + "database": "default", + "parameters": {"n": "1"} + }))) + .expect(1) + .mount(&server) + .await; + let query = client + .saved_query_get("org-1", "svc-1", "query-1") + .await + .unwrap() + .result + .unwrap(); + assert_eq!( + query.id, + Some(uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000003").unwrap()) + ); + assert_eq!(query.name.as_deref(), Some("example")); + assert_eq!(query.sql.as_deref(), Some("SELECT {n:UInt8}")); + assert_eq!(query.database.as_deref(), Some("default")); + assert_eq!( + query.parameters, + Some(std::collections::BTreeMap::from([( + "n".to_string(), + "1".to_string() + )])) + ); + Mock::given(method("DELETE")) + .and(path(query_path)) + .and(basic_auth("key", "secret")) + .respond_with(ok_empty()) + .expect(1) + .mount(&server) + .await; + let response = client + .saved_query_delete("org-1", "svc-1", "query-1") + .await + .unwrap(); + assert_eq!(response.status, Some(200)); + assert_eq!(response.request_id.as_deref(), Some("req-test")); + assert!(response.result.is_none()); + for request in server.received_requests().await.unwrap() { + assert!(request.body.is_empty()); + assert!(request.url.query().is_none()); + } +} + +#[tokio::test] +async fn saved_query_list_encodes_cursor_and_reads_envelope_pagination() { + for (cursor, limit) in [ + (None, None), + (Some("next+/=&? page"), None), + (None, Some(25)), + (Some("next+/=&? page"), Some(25)), + ] { + let (server, client) = setup().await; + let items = serde_json::json!([ + {"id": "00000000-0000-4000-8000-000000000003", "name": "example", "database": "default"} + ]); + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/saved-queries")) + .and(basic_auth("key", "secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "requestId": "req-test", + "result": items, + "limit": 25, + "totalCount": 2, + "nextCursor": "another-page" + }))) + .expect(1) + .mount(&server) + .await; + let response = client + .saved_query_list("org-1", "svc-1", cursor, limit) + .await + .unwrap(); + assert_eq!(response.limit, Some(25)); + assert_eq!(response.total_count, Some(2)); + assert_eq!(response.next_cursor.as_deref(), Some("another-page")); + assert_eq!( + serde_json::to_value(response.result.unwrap()).unwrap(), + items + ); + let requests = server.received_requests().await.unwrap(); + let query: std::collections::HashMap<_, _> = + requests[0].url.query_pairs().into_owned().collect(); + let mut expected = std::collections::HashMap::new(); + if let Some(cursor) = cursor { + expected.insert("cursor".to_owned(), cursor.to_owned()); + } + if let Some(limit) = limit { + expected.insert("limit".to_owned(), limit.to_string()); + } + assert_eq!(query, expected); + if cursor.is_none() && limit.is_none() { + assert!(requests[0].url.query().is_none()); + } + assert!(requests[0].body.is_empty()); + } +} + +#[tokio::test] +async fn saved_query_list_last_page_has_null_next_cursor() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org/services/svc/saved-queries")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "requestId": "req-test", + "result": [], + "limit": 100, + "totalCount": 0, + "nextCursor": null + }))) + .expect(1) + .mount(&server) + .await; + let response = client + .saved_query_list("org", "svc", None, None) + .await + .unwrap(); + assert_eq!(response.result, Some(vec![])); + assert_eq!(response.next_cursor, None); + assert_eq!(response.total_count, Some(0)); +} + +#[tokio::test] +async fn saved_query_reads_use_bearer_auth() { + let server = MockServer::start().await; + let client = Client::with_bearer_token(server.uri(), "token"); + for (query_path, result) in [ + ( + "/v1/organizations/org/services/svc/saved-queries", + serde_json::json!([]), + ), + ( + "/v1/organizations/org/services/svc/saved-queries/query", + serde_json::json!({}), + ), + ] { + Mock::given(method("GET")) + .and(path(query_path)) + .and(bearer_token("token")) + .respond_with(ok_json(result)) + .expect(1) + .mount(&server) + .await; + } + client + .saved_query_list("org", "svc", None, None) + .await + .unwrap(); + client.saved_query_get("org", "svc", "query").await.unwrap(); +} + +#[tokio::test] +async fn saved_query_methods_propagate_api_errors() { + for (status, body, expected_message) in [ + ( + 404, + serde_json::json!({"status": 404, "error": "Saved query not found"}).to_string(), + "Saved query not found", + ), + ( + 409, + serde_json::json!({"status": 409, "error": "name already exists"}).to_string(), + "name already exists", + ), + (500, "upstream failed".to_owned(), "upstream failed"), + ] { + let (server, client) = setup().await; + Mock::given(basic_auth("key", "secret")) + .respond_with(ResponseTemplate::new(status).set_body_string(body)) + .expect(5) + .mount(&server) + .await; + let request = PublicSavedQueryRequest { + name: "example".into(), + sql: "SELECT 1".into(), + database: "default".into(), + parameters: None, + }; + let errors = [ + client + .saved_query_create("org", "svc", &request) + .await + .unwrap_err(), + client + .saved_query_get("org", "svc", "query") + .await + .unwrap_err(), + client + .saved_query_list("org", "svc", None, None) + .await + .unwrap_err(), + client + .saved_query_update("org", "svc", "query", &request) + .await + .unwrap_err(), + client + .saved_query_delete("org", "svc", "query") + .await + .unwrap_err(), + ]; + for error in errors { + assert!( + matches!(error, clickhouse_cloud_api::Error::Api { status: actual_status, message } if actual_status == status && message == expected_message) + ); + } + } +} diff --git a/crates/clickhouse-cloud-api/tests/client/services.rs b/crates/clickhouse-cloud-api/tests/client/services.rs new file mode 100644 index 00000000..43dfaa92 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/services.rs @@ -0,0 +1,859 @@ +use clickhouse_cloud_api::{Client, models::*}; +use wiremock::matchers::{basic_auth, body_json, body_partial_json, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::{ok_empty, ok_json, setup}; + +// =========================================================================== +// Services (instances) +// =========================================================================== + +#[tokio::test] +async fn list_services() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-123/services")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": [ + { + "id": "11111111-2222-3333-4444-555555555555", + "name": "svc-1", + "provider": "aws", + "region": "us-east-1", + "state": "running", + "tier": "production" + } + ] + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let resp = client.instance_get_list("org-123", &[]).await.unwrap(); + let services = resp.result.unwrap(); + assert_eq!(services.len(), 1); + assert_eq!(services[0].name.as_deref(), Some("svc-1")); + assert_eq!(services[0].provider, Some(ServiceProvider::Aws)); + assert_eq!(services[0].state, Some(ServiceState::Running)); +} + +#[tokio::test] +async fn create_service() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/organizations/org-123/services")) + .and(body_partial_json( + serde_json::json!({"name": "new-service", "provider": "aws", "region": "us-east-1"}), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": { + "service": { + "id": "22222222-3333-4444-5555-666666666666", + "name": "new-service", + "provider": "aws", + "region": "us-east-1", + "state": "provisioning", + "tier": "production" + }, + "password": "generated-password-123" + } + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let body = ServicePostRequest { + name: "new-service".to_string(), + provider: ServicePostRequestProvider::Aws, + region: ServicePostRequestRegion::Us_east_1, + #[cfg(feature = "deprecated-fields")] + tier: Some(ServicePostRequestTier::Production), + ..Default::default() + }; + let resp = client.instance_create("org-123", &body).await.unwrap(); + let result = resp.result.unwrap(); + assert_eq!(result.password.as_deref(), Some("generated-password-123")); +} + +#[tokio::test] +async fn get_service_details() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": { + "id": "11111111-2222-3333-4444-555555555555", + "name": "prod-service", + "provider": "gcp", + "region": "us-east1", + "state": "running", + "tier": "production", + "numReplicas": 3, + "endpoints": [ + { + "protocol": "https", + "host": "abc.clickhouse.cloud", + "port": 8443 + } + ] + } + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let resp = client.instance_get("org-1", "svc-1").await.unwrap(); + let svc = resp.result.unwrap(); + assert_eq!(svc.name.as_deref(), Some("prod-service")); + assert_eq!(svc.provider, Some(ServiceProvider::Gcp)); + assert_eq!(svc.num_replicas, Some(3)); +} + +#[tokio::test] +async fn update_service() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/services/svc-1")) + .and(body_partial_json( + serde_json::json!({"name": "renamed-svc"}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "11111111-2222-3333-4444-555555555555", + "name": "renamed-svc", + "state": "running" + }))) + .mount(&s) + .await; + + let body = ServicePatchRequest { + name: Some("renamed-svc".to_string()), + ..Default::default() + }; + let resp = c.instance_update("org-1", "svc-1", &body).await.unwrap(); + let svc = resp.result.unwrap(); + assert_eq!(svc.name.as_deref(), Some("renamed-svc")); +} + +#[tokio::test] +async fn delete_service() { + let mock_server = MockServer::start().await; + + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-123/services/svc-456")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "requestId": "req-del-123" + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let resp = client.instance_delete("org-123", "svc-456").await.unwrap(); + assert_eq!(resp.status, Some(200)); +} + +#[tokio::test] +async fn update_service_state() { + let mock_server = MockServer::start().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-123/services/svc-456/state")) + .and(body_partial_json(serde_json::json!({"command": "stop"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": { + "id": "11111111-2222-3333-4444-555555555555", + "name": "my-svc", + "state": "stopping" + } + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let body = ServiceStatePatchRequest { + command: Some(ServiceStatePatchRequestCommand::Stop), + }; + let resp = client + .instance_state_update("org-123", "svc-456", &body) + .await + .unwrap(); + let svc = resp.result.unwrap(); + assert_eq!(svc.state, Some(ServiceState::Stopping)); +} + +#[tokio::test] +async fn update_service_password() { + let mock_server = MockServer::start().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/services/svc-1/password")) + .and(body_partial_json(serde_json::json!({}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "result": { + "password": "new-password-abc" + } + }))) + .mount(&mock_server) + .await; + + let client = Client::with_base_url(mock_server.uri(), "key", "secret"); + let body = ServicePasswordPatchRequest { + ..Default::default() + }; + let resp = client + .instance_password_update("org-1", "svc-1", &body) + .await + .unwrap(); + let result = resp.result.unwrap(); + assert_eq!(result.password.as_deref(), Some("new-password-abc")); +} + +// =========================================================================== +// Service sub-resources: scaling, private endpoints, query endpoints, prometheus +// =========================================================================== + +#[tokio::test] +async fn update_replica_scaling() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/services/svc-1/replicaScaling")) + .and(body_partial_json(serde_json::json!({"numReplicas": 5, "minReplicaMemoryGb": 16.0, "maxReplicaMemoryGb": 64.0}))) + .respond_with(ok_json(serde_json::json!({ + "id": "11111111-2222-3333-4444-555555555555", + "name": "svc-1", + "numReplicas": 5, + "minReplicaMemoryGb": 16, + "maxReplicaMemoryGb": 64 + }))) + .mount(&s) + .await; + + let body = ServiceReplicaScalingPatchRequest { + num_replicas: Some(5), + min_replica_memory_gb: Some(16.0), + max_replica_memory_gb: Some(64.0), + ..Default::default() + }; + let resp = c + .instance_replica_scaling_update("org-1", "svc-1", &body) + .await + .unwrap(); + let result = resp.result.unwrap(); + assert_eq!(result.num_replicas, Some(5)); +} + +#[tokio::test] +#[allow(deprecated)] +async fn update_scaling_deprecated() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path("/v1/organizations/org-1/services/svc-1/scaling")) + .and(body_partial_json(serde_json::json!({"numReplicas": 3}))) + .respond_with(ok_json(serde_json::json!({ + "id": "11111111-2222-3333-4444-555555555555", + "name": "svc-1", + "numReplicas": 3 + }))) + .mount(&s) + .await; + + let body = ServiceScalingPatchRequest { + num_replicas: Some(3), + ..Default::default() + }; + let resp = c + .instance_scaling_update("org-1", "svc-1", &body) + .await + .unwrap(); + let svc = resp.result.unwrap(); + assert_eq!(svc.num_replicas, Some(3)); +} + +#[tokio::test] +async fn create_private_endpoint() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/privateEndpoint", + )) + .and(body_partial_json( + serde_json::json!({"id": "vpce-abc", "description": "My PE"}), + )) + .respond_with(ok_json(serde_json::json!({ + "id": "pe-1", + "description": "My PE" + }))) + .mount(&s) + .await; + + let body = ServicPrivateEndpointePostRequest { + id: "vpce-abc".to_string(), + description: Some("My PE".to_string()), + }; + let resp = c + .instance_private_endpoint_create("org-1", "svc-1", &body) + .await + .unwrap(); + let pe = resp.result.unwrap(); + assert_eq!(pe.description.as_deref(), Some("My PE")); +} + +#[tokio::test] +async fn get_private_endpoint_config_for_service() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/privateEndpointConfig", + )) + .respond_with(ok_json(serde_json::json!({ + "endpointServiceId": "vpce-svc-abc", + "privateDnsHostname": "svc-1.private.clickhouse.cloud" + }))) + .mount(&s) + .await; + + let resp = c + .instance_private_endpoint_config_get("org-1", "svc-1") + .await + .unwrap(); + let config = resp.result.unwrap(); + assert_eq!(config.endpoint_service_id.as_deref(), Some("vpce-svc-abc")); + assert_eq!( + config.private_dns_hostname.as_deref(), + Some("svc-1.private.clickhouse.cloud") + ); +} + +#[tokio::test] +async fn get_service_prometheus_metrics() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/prometheus")) + .respond_with( + ResponseTemplate::new(200).set_body_string("# HELP svc_metric\nsvc_metric 100\n"), + ) + .mount(&s) + .await; + + let resp = c + .instance_prometheus_get("org-1", "svc-1", None) + .await + .unwrap(); + assert!(resp.contains("svc_metric")); +} + +#[tokio::test] +async fn get_service_prometheus_with_filter() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/prometheus")) + .and(query_param("filtered_metrics", "cpu,memory")) + .respond_with(ResponseTemplate::new(200).set_body_string("cpu 42\nmemory 1024\n")) + .mount(&s) + .await; + + let resp = c + .instance_prometheus_get("org-1", "svc-1", Some("cpu,memory")) + .await + .unwrap(); + assert!(resp.contains("cpu 42")); +} + +#[tokio::test] +async fn get_query_endpoint() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/serviceQueryEndpoint", + )) + .respond_with(ok_json(serde_json::json!({ + "id": "qe-1", + "allowedOrigins": "*", + "roles": ["sql_console_admin"] + }))) + .mount(&s) + .await; + + let resp = c + .instance_query_endpoint_get("org-1", "svc-1") + .await + .unwrap(); + let qe = resp.result.unwrap(); + assert_eq!(qe.id.as_deref(), Some("qe-1")); + assert_eq!(qe.allowed_origins.as_deref(), Some("*")); + assert_eq!(qe.roles, Some(vec![QueryEndpointRole::SqlConsoleAdmin])); +} + +#[tokio::test] +async fn upsert_query_endpoint() { + let (s, c) = setup().await; + + Mock::given(method("POST")) + .and(path( + "/v1/organizations/org-1/services/svc-1/serviceQueryEndpoint", + )) + .and(body_partial_json(serde_json::json!({ + "allowedOrigins": "https://example.com", + "roles": ["sql_console_read_only"] + }))) + .respond_with(ok_json(serde_json::json!({ + "id": "qe-1", + "allowedOrigins": "https://example.com", + "roles": ["sql_console_read_only"] + }))) + .mount(&s) + .await; + + let body = InstanceServiceQueryApiEndpointsPostRequest { + allowed_origins: Some("https://example.com".to_string()), + roles: vec![QueryEndpointRole::SqlConsoleReadOnly], + ..Default::default() + }; + let resp = c + .instance_query_endpoint_upsert("org-1", "svc-1", &body) + .await + .unwrap(); + let qe = resp.result.unwrap(); + assert_eq!(qe.allowed_origins.as_deref(), Some("https://example.com")); +} + +#[tokio::test] +async fn delete_query_endpoint() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/services/svc-1/serviceQueryEndpoint", + )) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c + .instance_query_endpoint_delete("org-1", "svc-1") + .await + .unwrap(); + assert_eq!(resp.status, Some(200)); +} + +// =========================================================================== +// Empty collection responses +// =========================================================================== + +#[tokio::test] +async fn list_services_returns_empty_vec() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + let resp = c.instance_get_list("org-1", &[]).await.unwrap(); + let services = resp.result.unwrap(); + assert!(services.is_empty()); +} + +// =========================================================================== +// Query parameter coverage +// =========================================================================== + +#[tokio::test] +async fn list_services_with_filters() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services")) + .and(query_param("filter", "state=running")) + .respond_with(ok_json(serde_json::json!([ + {"id": "11111111-2222-3333-4444-555555555555", "name": "svc-1", "state": "running"} + ]))) + .mount(&s) + .await; + + let resp = c + .instance_get_list("org-1", &["state=running"]) + .await + .unwrap(); + let services = resp.result.unwrap(); + assert_eq!(services.len(), 1); +} + +#[tokio::test] +async fn list_services_with_multiple_filters() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services")) + .and(query_param("filter", "state=running")) + .and(query_param("filter", "tier=production")) + .respond_with(ok_json(serde_json::json!([ + {"id": "11111111-2222-3333-4444-555555555555", "name": "svc-1", "state": "running"} + ]))) + .mount(&s) + .await; + + let resp = c + .instance_get_list("org-1", &["state=running", "tier=production"]) + .await + .unwrap(); + let services = resp.result.unwrap(); + assert_eq!(services.len(), 1); +} + +#[tokio::test] +async fn scaling_schedule_delete_succeeds() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/services/svc-1/scalingSchedule", + )) + .respond_with(ok_json(serde_json::json!({ + "status": 200, + "requestId": "00000000-0000-0000-0000-000000000000" + }))) + .mount(&s) + .await; + + let resp = c.scaling_schedule_delete("org-1", "svc-1").await.unwrap(); + assert_eq!(resp.status, Some(200)); +} + +// =========================================================================== +// Service: Upgrade Window +// =========================================================================== + +#[tokio::test] +async fn upgrade_window_get_returns_window() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/services/svc-1/upgradeWindow")) + .respond_with(ok_json(serde_json::json!({ + "weekday": 2, + "startHourUtc": 6, + "duration": 6 + }))) + .mount(&s) + .await; + + let resp = c.upgrade_window_get("org-1", "svc-1").await.unwrap(); + let window = resp.result.unwrap(); + assert_eq!(window.weekday, Some(2)); + assert_eq!( + window.start_hour_utc, + Some(UpgradeWindowStartHourUtc::Hour6) + ); + assert_eq!(window.duration, Some(UpgradeWindowDuration::SixHours)); +} + +#[tokio::test] +async fn upgrade_window_update_sends_body() { + let (s, c) = setup().await; + + Mock::given(method("PUT")) + .and(path("/v1/organizations/org-1/services/svc-1/upgradeWindow")) + .and(body_partial_json(serde_json::json!({ + "weekday": 2, + "startHourUtc": 6 + }))) + .respond_with(ok_json(serde_json::json!({ + "weekday": 2, + "startHourUtc": 6, + "duration": 6 + }))) + .mount(&s) + .await; + + let body = UpgradeWindowPutRequest { + weekday: 2, + start_hour_utc: UpgradeWindowStartHourUtc::Hour6, + }; + let resp = c + .upgrade_window_update("org-1", "svc-1", &body) + .await + .unwrap(); + let window = resp.result.unwrap(); + assert_eq!(window.weekday, Some(2)); + assert_eq!( + window.start_hour_utc, + Some(UpgradeWindowStartHourUtc::Hour6) + ); +} + +#[tokio::test] +async fn upgrade_window_delete_succeeds() { + let (s, c) = setup().await; + + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-1/services/svc-1/upgradeWindow")) + .respond_with(ok_empty()) + .mount(&s) + .await; + + let resp = c.upgrade_window_delete("org-1", "svc-1").await.unwrap(); + assert_eq!(resp.status, Some(200)); +} + +#[tokio::test] +async fn service_profiles_list_encodes_optional_region_and_byoc() { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org/serviceProfiles")) + .and(basic_auth("key", "secret")) + .respond_with(ok_json( + serde_json::json!([{"profile": "v1-standard-byoc-4", "cpuCores": 4, "memoryGi": 16}]), + )) + .expect(4) + .mount(&server) + .await; + let region = "region +/=&?雪"; + let byoc = "byoc +/=&?雪"; + for (region_id, byoc_id) in [ + (None, None), + (Some(region), None), + (None, Some(byoc)), + (Some(region), Some(byoc)), + ] { + let result = client + .service_profiles_list("org", region_id, byoc_id) + .await + .unwrap() + .result + .unwrap(); + assert_eq!(result[0].profile.as_deref(), Some("v1-standard-byoc-4")); + assert_eq!(result[0].cpu_cores, Some(4.0)); + assert_eq!(result[0].memory_gi, Some(16.0)); + } + let requests = server.received_requests().await.unwrap(); + let queries: Vec> = requests + .iter() + .map(|request| request.url.query_pairs().into_owned().collect()) + .collect(); + assert_eq!( + queries, + vec![ + vec![], + vec![("region_id".into(), region.into())], + vec![("byoc_id".into(), byoc.into())], + vec![ + ("region_id".into(), region.into()), + ("byoc_id".into(), byoc.into()) + ], + ] + ); +} + +#[tokio::test] +async fn service_clickhouse_settings_update_sends_and_receives_objects() { + let (server, client) = setup().await; + let settings = serde_json::json!({"compatibility": "26.2", "max_query_size": 262144}); + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/clickhouseSettings", + )) + .and(basic_auth("key", "secret")) + .and(body_json(serde_json::json!({"settings": settings}))) + .respond_with(ok_json( + serde_json::json!({"settings": settings, "warnings": []}), + )) + .expect(3) + .mount(&server) + .await; + let body: ServiceClickhouseSettingsPatchRequest = + serde_json::from_value(serde_json::json!({"settings": settings})).unwrap(); + let response = client + .service_clickhouse_settings_update("org-1", "svc-1", &body) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(response.result.unwrap().settings.unwrap()).unwrap(), + settings + ); + let legacy = ServiceClickhouseSettingsPatchRequest { + settings: Some(settings.to_string()), + }; + client + .service_clickhouse_settings_update("org-1", "svc-1", &legacy) + .await + .unwrap(); + let typed = ServiceClickhouseSettingsPatchRequest { + settings: Some(serde_json::from_value::(settings).unwrap()), + }; + client + .service_clickhouse_settings_update("org-1", "svc-1", &typed) + .await + .unwrap(); +} + +#[tokio::test] +async fn service_clickhouse_settings_update_rejects_invalid_legacy_input_before_http() { + let (server, client) = setup().await; + for invalid in ["{broken", "{}", "[]", "null", "42", r#""string""#] { + let body = ServiceClickhouseSettingsPatchRequest { + settings: Some(invalid.to_string()), + }; + assert!( + client + .service_clickhouse_settings_update("org-1", "svc-1", &body) + .await + .is_err() + ); + } + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn service_clickhouse_setting_reads_preserve_numeric_and_string_values() { + let (server, client) = setup().await; + let collection = "/v1/organizations/org-1/services/svc-1/clickhouseSettings"; + let settings = serde_json::json!([ + {"name": "max_query_size", "value": 262146}, + {"name": "compatibility", "value": "26.2"}, + {"name": "future_bool", "value": false} + ]); + for setting in settings.as_array().unwrap() { + let name = setting["name"].as_str().unwrap(); + Mock::given(method("GET")) + .and(path(format!("{collection}/{name}"))) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(setting.clone())) + .expect(1) + .mount(&server) + .await; + let response = client + .service_clickhouse_setting_get("org-1", "svc-1", name) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(response.result.unwrap()).unwrap(), + *setting + ); + } + Mock::given(method("GET")) + .and(path(collection)) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!({"settings": settings}))) + .expect(1) + .mount(&server) + .await; + let response = client + .service_clickhouse_settings_list_get("org-1", "svc-1") + .await + .unwrap(); + assert_eq!( + serde_json::to_value(response.result.unwrap()).unwrap(), + serde_json::json!({"settings": settings}) + ); +} + +// --------------------------------------------------------------------------- +// Replica aware routing +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn get_replica_aware_routing() { + let (s, c) = setup().await; + + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/services/svc-1/replicaAwareRouting", + )) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(serde_json::json!({ + "httpEnabled": false, + "nativeEnabled": true + }))) + .expect(1) + .mount(&s) + .await; + + let resp = c.replica_aware_routing_get("org-1", "svc-1").await.unwrap(); + let routing = resp.result.unwrap(); + assert_eq!(routing.http_enabled, Some(false)); + assert_eq!(routing.native_enabled, Some(true)); +} + +#[tokio::test] +async fn update_replica_aware_routing_sends_only_set_fields() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/replicaAwareRouting", + )) + .and(basic_auth("key", "secret")) + .and(body_json(serde_json::json!({"httpEnabled": true}))) + .respond_with(ok_json(serde_json::json!({ + "httpEnabled": true, + "nativeEnabled": false + }))) + .expect(1) + .mount(&s) + .await; + + let body = ReplicaAwareRoutingPatchRequest { + http_enabled: Some(true), + native_enabled: None, + }; + let resp = c + .replica_aware_routing_update("org-1", "svc-1", &body) + .await + .unwrap(); + let routing = resp.result.unwrap(); + assert_eq!(routing.http_enabled, Some(true)); + assert_eq!(routing.native_enabled, Some(false)); +} + +#[tokio::test] +async fn replica_aware_routing_update_surfaces_api_errors() { + let (s, c) = setup().await; + + Mock::given(method("PATCH")) + .and(path( + "/v1/organizations/org-1/services/svc-1/replicaAwareRouting", + )) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "status": 400, + "error": "Replica aware routing is not enabled for this organization" + }))) + .mount(&s) + .await; + + let body = ReplicaAwareRoutingPatchRequest { + http_enabled: None, + native_enabled: Some(true), + }; + let err = c + .replica_aware_routing_update("org-1", "svc-1", &body) + .await + .unwrap_err(); + match err { + clickhouse_cloud_api::Error::Api { status, message } => { + assert_eq!(status, 400); + assert_eq!( + message, + "Replica aware routing is not enabled for this organization" + ); + } + other => panic!("unexpected error: {other:?}"), + } +} diff --git a/crates/clickhouse-cloud-api/tests/client/udfs.rs b/crates/clickhouse-cloud-api/tests/client/udfs.rs new file mode 100644 index 00000000..b9c1ca2b --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/udfs.rs @@ -0,0 +1,509 @@ +use clickhouse_cloud_api::models::*; +use wiremock::matchers::{basic_auth, body_json, body_partial_json, method, path, query_param}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::{created_json, ok_empty, ok_json, setup}; + +// =========================================================================== +// UDFs +// =========================================================================== + +#[tokio::test] +async fn delete_udf() { + let (s, c) = setup().await; + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-1/udfs/my_udf")) + .respond_with(ok_empty()) + .mount(&s) + .await; + + assert_eq!( + c.udf_delete("org-1", "my_udf").await.unwrap().status, + Some(200) + ); +} + +#[tokio::test] +async fn detach_udf() { + let (s, c) = setup().await; + Mock::given(method("DELETE")) + .and(path( + "/v1/organizations/org-1/udfs/my_udf/attachments/svc-1", + )) + .respond_with(ok_empty()) + .mount(&s) + .await; + + assert_eq!( + c.udf_detach("org-1", "my_udf", "svc-1") + .await + .unwrap() + .status, + Some(200) + ); +} + +#[tokio::test] +async fn delete_udf_version() { + let (s, c) = setup().await; + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-1/udfs/my_udf/versions/7")) + .respond_with(ok_empty()) + .mount(&s) + .await; + + assert_eq!( + c.udf_version_delete("org-1", "my_udf", 7) + .await + .unwrap() + .status, + Some(200) + ); +} + +#[tokio::test] +async fn list_udfs_encodes_pagination() { + let (s, c) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/udfs")) + .and(query_param("cursor", "next-page")) + .and(query_param("limit", "25")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + assert!( + c.udf_list("org-1", Some("next-page"), Some(25)) + .await + .unwrap() + .result + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn get_udf() { + let (s, c) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/udfs/my_udf")) + .respond_with(ok_json(serde_json::json!({"functionName": "my_udf"}))) + .mount(&s) + .await; + + assert_eq!( + c.udf_get("org-1", "my_udf") + .await + .unwrap() + .result + .unwrap() + .function_name + .as_deref(), + Some("my_udf") + ); +} + +#[tokio::test] +async fn list_udf_attachments_encodes_pagination() { + let (s, c) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/udfs/my_udf/attachments")) + .and(query_param("cursor", "next-page")) + .and(query_param("limit", "25")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + assert!( + c.udf_attachment_list("org-1", "my_udf", Some("next-page"), Some(25)) + .await + .unwrap() + .result + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn get_udf_attachment() { + let (s, c) = setup().await; + Mock::given(method("GET")) + .and(path( + "/v1/organizations/org-1/udfs/my_udf/attachments/svc-1", + )) + .respond_with(ok_json(serde_json::json!({"serviceId": "svc-1"}))) + .mount(&s) + .await; + + assert_eq!( + c.udf_attachment_get("org-1", "my_udf", "svc-1") + .await + .unwrap() + .result + .unwrap() + .service_id + .as_deref(), + Some("svc-1") + ); +} + +#[tokio::test] +async fn list_udf_versions_encodes_pagination() { + let (s, c) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/udfs/my_udf/versions")) + .and(query_param("cursor", "next-page")) + .and(query_param("limit", "25")) + .respond_with(ok_json(serde_json::json!([]))) + .mount(&s) + .await; + + assert!( + c.udf_version_list("org-1", "my_udf", Some("next-page"), Some(25)) + .await + .unwrap() + .result + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn udf_list_pages_decode_arrays_and_nullable_outer_metadata() { + for endpoint in ["", "/my_udf/versions", "/my_udf/attachments"] { + for results in [ + serde_json::json!([]), + serde_json::json!([{ + "functionName":"my_udf", "serviceId":"svc-1", "version":7, + "status":"future-status", "runtime":null, "futureField":true + }]), + ] { + for metadata in [ + serde_json::json!({"limit":1,"totalCount":3,"nextCursor":"page /+?"}), + serde_json::json!({"limit":null,"totalCount":null,"nextCursor":null}), + serde_json::json!({}), + ] { + let (server, client) = setup().await; + let mut wire = metadata.clone(); + wire["result"] = results.clone(); + wire["status"] = 200.into(); + wire["requestId"] = "udf-page".into(); + Mock::given(method("GET")) + .and(path(format!("/v1/organizations/org-1/udfs{endpoint}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(wire)) + .expect(1) + .mount(&server) + .await; + let decoded = match endpoint { + "" => serde_json::to_value(client.udf_list("org-1", None, None).await.unwrap()) + .unwrap(), + "/my_udf/versions" => serde_json::to_value( + client + .udf_version_list("org-1", "my_udf", None, None) + .await + .unwrap(), + ) + .unwrap(), + _ => serde_json::to_value( + client + .udf_attachment_list("org-1", "my_udf", None, None) + .await + .unwrap(), + ) + .unwrap(), + }; + assert_eq!( + decoded["result"].as_array().unwrap().len(), + results.as_array().unwrap().len() + ); + assert_eq!(decoded["status"], 200); + assert_eq!(decoded["requestId"], "udf-page"); + for field in ["limit", "totalCount", "nextCursor"] { + assert_eq!(decoded[field], metadata[field]); + if metadata[field].is_null() { + assert!(decoded.get(field).is_none()); + } + } + if !results.as_array().unwrap().is_empty() { + assert_eq!(decoded["result"][0]["version"], 7); + assert_eq!(decoded["result"][0]["status"], "future-status"); + assert!(decoded["result"][0].get("futureField").is_none()); + } + } + } + } +} + +#[tokio::test] +async fn create_udf_upload_session() { + let (s, c) = setup().await; + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/udfUploads/url")) + .respond_with(created_json(serde_json::json!({"uploadId": "upload-1"}))) + .mount(&s) + .await; + + assert_eq!( + c.udf_upload_session_create("org-1") + .await + .unwrap() + .result + .unwrap() + .upload_id + .as_deref(), + Some("upload-1") + ); +} + +#[tokio::test] +async fn create_udf_encodes_request_body() { + let (s, c) = setup().await; + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/udfs")) + .and(body_partial_json(serde_json::json!({ + "arguments": [], + "functionName": "my_udf", + "returnType": "String", + "runtime": "python3.11", + "type": "executable", + "uploadId": "upload-1", "deterministic": false, "memoryLimitMib": 256 + }))) + .respond_with(created_json(serde_json::json!({"functionName": "my_udf"}))) + .mount(&s) + .await; + + let body = UdfCreateRequest::UdfCreateRequestV1(UdfCreateRequestV1 { + deterministic: Some(false), + memory_limit_mib: Some(256), + arguments: vec![], + function_name: "my_udf".to_string(), + return_type: "String".to_string(), + runtime: UdfRuntime::Python3_11, + r#type: UdfCreateRequestV1Type::Executable, + upload_id: "upload-1".to_string(), + ..Default::default() + }); + assert_eq!( + c.udf_create("org-1", &body) + .await + .unwrap() + .result + .unwrap() + .function_name + .as_deref(), + Some("my_udf") + ); +} + +#[tokio::test] +async fn create_udf_version_encodes_request_body() { + let (s, c) = setup().await; + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/udfs/my_udf/versions")) + .and(body_partial_json(serde_json::json!({ + "arguments": [], + "returnType": "String", + "runtime": "python3.11", + "type": "executable", + "uploadId": "upload-1", "deterministic": false, "memoryLimitMib": 256 + }))) + .respond_with(created_json(serde_json::json!({"functionName": "my_udf"}))) + .mount(&s) + .await; + + let body = UdfVersionCreateRequest::UdfVersionCreateRequestV1(UdfVersionCreateRequestV1 { + deterministic: Some(false), + memory_limit_mib: Some(256), + arguments: vec![], + return_type: "String".to_string(), + runtime: UdfRuntime::Python3_11, + r#type: UdfVersionCreateRequestV1Type::Executable, + upload_id: "upload-1".to_string(), + ..Default::default() + }); + assert_eq!( + c.udf_version_create("org-1", "my_udf", &body) + .await + .unwrap() + .result + .unwrap() + .function_name + .as_deref(), + Some("my_udf") + ); +} + +#[tokio::test] +async fn attach_udf_encodes_optional_version() { + let (s, c) = setup().await; + let attachment_path = "/v1/organizations/org-1/udfs/my_udf/attachments/svc-1"; + let attachment = serde_json::json!({"functionName": "my_udf", "serviceId": "svc-1"}); + + Mock::given(method("PUT")) + .and(path(attachment_path)) + .and(body_json(serde_json::json!({}))) + .respond_with(ok_json(attachment.clone())) + .mount(&s) + .await; + Mock::given(method("PUT")) + .and(path(attachment_path)) + .and(body_json(serde_json::json!({"version": 7}))) + .respond_with(ok_json(attachment)) + .mount(&s) + .await; + + assert_eq!( + c.udf_attach("org-1", "my_udf", "svc-1", None) + .await + .unwrap() + .result + .unwrap() + .service_id + .as_deref(), + Some("svc-1") + ); + assert_eq!( + c.udf_attach("org-1", "my_udf", "svc-1", Some(7)) + .await + .unwrap() + .result + .unwrap() + .function_name + .as_deref(), + Some("my_udf") + ); +} + +#[tokio::test] +async fn attach_udf_preserves_structured_dependency_errors() { + for (wire_code, expected_code, state, can_wake) in [ + ( + "SERVICE_IDLE", + UdfAttachErrorCode::ServiceIdle, + "idle", + true, + ), + ( + "SERVICE_STOPPED", + UdfAttachErrorCode::ServiceStopped, + "stopped", + false, + ), + ( + "SERVICE_NOT_RUNNING", + UdfAttachErrorCode::ServiceNotRunning, + "starting", + false, + ), + ( + "FUTURE_CODE", + UdfAttachErrorCode::Unknown("FUTURE_CODE".into()), + "future_state", + false, + ), + ] { + let (server, client) = setup().await; + let body = serde_json::json!({ + "error": "service unavailable", "code": wire_code, "serviceState": state, + "canWake": can_wake, "status": 424, + "requestId": "00000000-0000-0000-0000-000000000001", "futureField": 1 + }); + Mock::given(method("PUT")) + .and(path( + "/v1/organizations/org-1/udfs/my_udf/attachments/svc-1", + )) + .and(basic_auth("key", "secret")) + .respond_with(ResponseTemplate::new(424).set_body_json(&body)) + .expect(1) + .mount(&server) + .await; + let error = client + .udf_attach("org-1", "my_udf", "svc-1", None) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + "API error (status 424): service unavailable" + ); + let clickhouse_cloud_api::Error::UdfAttachmentUnavailable { + status, + message, + response, + } = error + else { + panic!("expected typed UDF dependency error"); + }; + assert_eq!(status, 424); + assert_eq!(message, "service unavailable"); + assert_eq!(response.code, Some(expected_code)); + assert_eq!(response.service_state.as_ref().unwrap().to_string(), state); + assert_eq!(response.can_wake, Some(can_wake)); + let mut expected = body; + expected.as_object_mut().unwrap().remove("futureField"); + assert_eq!(serde_json::to_value(response).unwrap(), expected); + server.verify().await; + } +} + +#[tokio::test] +async fn attach_udf_dependency_errors_tolerate_absent_and_null_fields() { + for body in [ + serde_json::json!({}), + serde_json::json!({ + "error": null, "code": null, "serviceState": null, "canWake": null, + "status": null, "requestId": null + }), + ] { + let (server, client) = setup().await; + Mock::given(method("PUT")) + .respond_with(ResponseTemplate::new(424).set_body_json(body)) + .expect(1) + .mount(&server) + .await; + let error = client + .udf_attach("org-1", "my_udf", "svc-1", None) + .await + .unwrap_err(); + let clickhouse_cloud_api::Error::UdfAttachmentUnavailable { response, .. } = error else { + panic!("expected tolerant UDF dependency error"); + }; + assert_eq!(*response, UdfAttachResponse424::default()); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({}) + ); + } +} + +#[tokio::test] +async fn attach_udf_keeps_generic_errors_for_other_statuses_and_invalid_bodies() { + for (status, body, expected_message) in [ + (424, "upstream unavailable", "upstream unavailable"), + (424, r#"{"error":"bad response","code":42}"#, "bad response"), + ( + 403, + r#"{"error":"forbidden","code":"SERVICE_IDLE"}"#, + "forbidden", + ), + ( + 500, + r#"{"error":"failed","code":"SERVICE_STOPPED"}"#, + "failed", + ), + ] { + let (server, client) = setup().await; + Mock::given(method("PUT")) + .respond_with(ResponseTemplate::new(status).set_body_string(body)) + .expect(1) + .mount(&server) + .await; + let error = client + .udf_attach("org-1", "my_udf", "svc-1", None) + .await + .unwrap_err(); + assert!( + matches!(error, clickhouse_cloud_api::Error::Api { status: actual, message } + if actual == status && message == expected_message) + ); + } +} diff --git a/crates/clickhouse-cloud-api/tests/client/whoami.rs b/crates/clickhouse-cloud-api/tests/client/whoami.rs new file mode 100644 index 00000000..cc7deb9c --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/client/whoami.rs @@ -0,0 +1,130 @@ +use clickhouse_cloud_api::{Client, models::*}; +use wiremock::matchers::{basic_auth, bearer_token, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::{ok_json, setup}; + +// =========================================================================== +// Whoami +// =========================================================================== + +#[tokio::test] +async fn whoami_get_parses_user_identity_with_bearer_auth() { + let server = MockServer::start().await; + let client = Client::with_bearer_token(server.uri(), "token"); + let wire = serde_json::json!({ + "actorType": "user", + "userId": "00000000-0000-4000-8000-000000000001", + "email": "user@example.com", + "name": "Example User", + "organizations": [ + { + "organizationId": "00000000-0000-4000-8000-000000000002", + "organizationName": "Example Org" + } + ] + }); + Mock::given(method("GET")) + .and(path("/v1/whoami")) + .and(bearer_token("token")) + .respond_with(ok_json(wire.clone())) + .expect(1) + .mount(&server) + .await; + let response = client.whoami_get().await.unwrap(); + assert_eq!(response.status, Some(200)); + let whoami = response.result.unwrap(); + let Whoami::WhoamiUser(user) = &whoami else { + panic!("expected a user identity, got {whoami:?}"); + }; + assert_eq!(user.actor_type, Some(WhoamiUserActortype::User)); + assert_eq!( + user.user_id, + Some(uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000001").unwrap()) + ); + assert_eq!(user.email.as_deref(), Some("user@example.com")); + assert_eq!(user.name.as_deref(), Some("Example User")); + let organizations = user.organizations.as_ref().unwrap(); + assert_eq!( + organizations[0].organization_id, + Some(uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000002").unwrap()) + ); + assert_eq!( + organizations[0].organization_name.as_deref(), + Some("Example Org") + ); + assert_eq!(serde_json::to_value(&whoami).unwrap(), wire); + + let requests = server.received_requests().await.unwrap(); + assert!(requests[0].url.query().is_none()); + assert!(requests[0].body.is_empty()); +} + +#[tokio::test] +async fn whoami_get_parses_api_key_identity_with_basic_auth() { + let (server, client) = setup().await; + let wire = serde_json::json!({ + "actorType": "apiKey", + "keyId": "key-1", + "name": "ci key", + "organizationId": "00000000-0000-4000-8000-000000000002" + }); + Mock::given(method("GET")) + .and(path("/v1/whoami")) + .and(basic_auth("key", "secret")) + .respond_with(ok_json(wire.clone())) + .expect(1) + .mount(&server) + .await; + let whoami = client.whoami_get().await.unwrap().result.unwrap(); + let Whoami::WhoamiApiKey(key) = &whoami else { + panic!("expected an API key identity, got {whoami:?}"); + }; + assert_eq!(key.actor_type, Some(WhoamiApiKeyActortype::ApiKey)); + assert_eq!(key.key_id.as_deref(), Some("key-1")); + assert_eq!(key.name.as_deref(), Some("ci key")); + assert_eq!( + key.organization_id, + Some(uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000002").unwrap()) + ); + assert_eq!(serde_json::to_value(&whoami).unwrap(), wire); +} + +#[tokio::test] +async fn whoami_get_keeps_unknown_actor_type_verbatim() { + let (server, client) = setup().await; + let wire = serde_json::json!({"actorType": "serviceAccount", "accountId": "sa-1"}); + Mock::given(method("GET")) + .and(path("/v1/whoami")) + .respond_with(ok_json(wire.clone())) + .expect(1) + .mount(&server) + .await; + let whoami = client.whoami_get().await.unwrap().result.unwrap(); + assert_eq!(whoami, Whoami::Unknown(wire)); +} + +#[tokio::test] +async fn whoami_get_propagates_api_errors() { + for (status, body, expected_message) in [ + ( + 401, + serde_json::json!({"status": 401, "error": "Unauthorized"}).to_string(), + "Unauthorized", + ), + (500, "upstream failed".to_owned(), "upstream failed"), + ] { + let (server, client) = setup().await; + Mock::given(method("GET")) + .and(path("/v1/whoami")) + .respond_with(ResponseTemplate::new(status).set_body_string(body)) + .expect(1) + .mount(&server) + .await; + let error = client.whoami_get().await.unwrap_err(); + assert!( + matches!(error, clickhouse_cloud_api::Error::Api { status: actual_status, ref message } if actual_status == status && message == expected_message), + "unexpected error: {error:?}" + ); + } +} diff --git a/crates/clickhouse-cloud-api/tests/client_test.rs b/crates/clickhouse-cloud-api/tests/client_test.rs deleted file mode 100644 index 2797ed8a..00000000 --- a/crates/clickhouse-cloud-api/tests/client_test.rs +++ /dev/null @@ -1,7152 +0,0 @@ -use chrono::Utc; -use clickhouse_cloud_api::{Client, models::*}; -use wiremock::matchers::{ - basic_auth, bearer_token, body_json, body_partial_json, method, path, query_param, -}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -// --------------------------------------------------------------------------- -// Helper -// --------------------------------------------------------------------------- - -fn ok_json(result: serde_json::Value) -> ResponseTemplate { - ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "requestId": "req-test", - "result": result - })) -} - -fn ok_empty() -> ResponseTemplate { - ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "requestId": "req-test" - })) -} - -fn created_json(result: serde_json::Value) -> ResponseTemplate { - ResponseTemplate::new(201).set_body_json(serde_json::json!({ - "status": 201, - "requestId": "req-test", - "result": result - })) -} - -async fn setup() -> (MockServer, Client) { - let s = MockServer::start().await; - let c = Client::with_base_url(s.uri(), "key", "secret"); - (s, c) -} - -// =========================================================================== -// Organizations -// =========================================================================== - -#[tokio::test] -async fn list_organizations() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .and(basic_auth("test-key", "test-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "requestId": "req-123", - "result": [ - { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "Test Org" - } - ] - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "test-key", "test-secret"); - let resp = client.organization_get_list(None, None).await.unwrap(); - assert_eq!(resp.status, Some(200)); - let orgs = resp.result.unwrap(); - assert_eq!(orgs.len(), 1); - assert_eq!(orgs[0].name.as_deref(), Some("Test Org")); -} - -#[tokio::test] -async fn get_organization() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-123")) - .and(basic_auth("my-key", "my-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "My Org", - "createdAt": "2024-01-01T00:00:00Z" - } - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "my-key", "my-secret"); - let resp = client.organization_get("org-123").await.unwrap(); - let org = resp.result.unwrap(); - assert_eq!(org.name.as_deref(), Some("My Org")); -} - -#[tokio::test] -async fn get_organization_preserves_snapshot_capability() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1")) - .respond_with(ok_json(serde_json::json!({ - "capabilities": {"snapshots": false} - }))) - .mount(&server) - .await; - let org = client - .organization_get("org-1") - .await - .unwrap() - .result - .unwrap(); - assert_eq!(org.capabilities.unwrap().snapshots, Some(false)); -} - -#[tokio::test] -async fn get_active_balances_with_pagination() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/activeBalances")) - .and(query_param("limit", "25")) - .and(query_param("offset", "50")) - .respond_with(ok_json(serde_json::json!({ - "totalRemainingPrepaidCredits": 12.5, - "prepaidBalances": [{ - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "remainingPrepaidCredits": 12.5, - "expirationDate": "2027-01-01T00:00:00Z" - }] - }))) - .mount(&s) - .await; - - let balances = c - .active_balances_get("org-1", Some(25), Some(50)) - .await - .unwrap() - .result - .unwrap(); - assert_eq!(balances.total_remaining_prepaid_credits, Some(12.5)); - assert_eq!(balances.prepaid_balances.unwrap().len(), 1); -} - -#[tokio::test] -async fn update_organization() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1")) - .and(body_partial_json( - serde_json::json!({"name": "Renamed Org"}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "Renamed Org" - }))) - .mount(&s) - .await; - - let body = OrganizationPatchRequest { - name: Some("Renamed Org".to_string()), - ..Default::default() - }; - let resp = c.organization_update("org-1", &body).await.unwrap(); - let org = resp.result.unwrap(); - assert_eq!(org.name.as_deref(), Some("Renamed Org")); -} - -#[tokio::test] -async fn get_usage_cost_with_query_params() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/usageCost")) - .and(query_param("from_date", "2024-01-01")) - .and(query_param("to_date", "2024-01-31")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": { - "costs": [], - "grandTotalCHC": 50.25 - } - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let resp = client - .usage_cost_get("org-1", "2024-01-01", "2024-01-31", &[]) - .await - .unwrap(); - let cost = resp.result.unwrap(); - assert_eq!(cost.grand_total_chc, Some(50.25)); -} - -#[tokio::test] -async fn get_prometheus_metrics() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/prometheus")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string("# HELP ch_metric A metric\nch_metric{service=\"svc-1\"} 42\n"), - ) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let resp = client - .organization_prometheus_get("org-1", None) - .await - .unwrap(); - assert!(resp.contains("ch_metric")); -} - -#[tokio::test] -async fn discover_organization_prometheus_targets() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/prometheus/discovery")) - .and(query_param("filtered_metrics", "false")) - .respond_with( - ResponseTemplate::new(200).set_body_json(serde_json::json!([{ - "targets": ["api.clickhouse.cloud"], - "labels": { - "__scheme__": "https", - "__metrics_path__": "/v1/organizations/org-1/services/svc-1/prometheus", - "__param_filtered_metrics": "false", - "clickhouse_org_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "clickhouse_service_id": "b1b2c3d4-e5f6-7890-abcd-ef1234567890", - "clickhouse_discovery_service_name": "analytics", - "__meta_clickhouse_region": "us-east-1", - "__meta_clickhouse_provider": "aws" - } - }])), - ) - .mount(&s) - .await; - - let groups = c - .organization_prometheus_discovery_get("org-1", Some("false")) - .await - .unwrap(); - assert_eq!(groups.len(), 1); - assert_eq!( - groups[0] - .labels - .as_ref() - .and_then(|labels| labels.scheme.as_deref()), - Some("https") - ); - let labels = groups[0].labels.as_ref().unwrap(); - assert_eq!(labels.meta_clickhouse_region.as_deref(), Some("us-east-1")); - assert_eq!(labels.meta_clickhouse_provider.as_deref(), Some("aws")); - assert_eq!(labels.additional_labels, None); -} - -#[tokio::test] -#[allow(deprecated)] -async fn get_private_endpoint_config() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/privateEndpointConfig")) - .and(query_param("cloud_provider", "aws")) - .and(query_param("region_id", "us-east-1")) - .respond_with(ok_json(serde_json::json!({ - "endpointServiceId": "com.amazonaws.vpce.us-east-1.vpce-svc-abc" - }))) - .mount(&s) - .await; - - let resp = c - .organization_private_endpoint_config_get_list("org-1", "aws", "us-east-1") - .await - .unwrap(); - let config = resp.result.unwrap(); - assert_eq!( - config.endpoint_service_id.as_deref(), - Some("com.amazonaws.vpce.us-east-1.vpce-svc-abc") - ); -} - -// =========================================================================== -// Activities -// =========================================================================== - -#[tokio::test] -async fn list_activities() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/activities")) - .respond_with(ok_json(serde_json::json!([ - { - "id": "act-1", - "type": "SERVICE_START", - "actorType": "user", - "organizationId": "org-1", - "serviceId": "svc-1" - } - ]))) - .mount(&s) - .await; - - let resp = c.activity_get_list("org-1", None, None).await.unwrap(); - let activities = resp.result.unwrap(); - assert_eq!(activities.len(), 1); - assert_eq!(activities[0].id.as_deref(), Some("act-1")); -} - -#[tokio::test] -async fn list_activities_with_date_filter() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/activities")) - .and(query_param("from_date", "2024-06-01")) - .and(query_param("to_date", "2024-06-30")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - let resp = c - .activity_get_list("org-1", Some("2024-06-01"), Some("2024-06-30")) - .await - .unwrap(); - assert_eq!(resp.result.unwrap().len(), 0); -} - -#[tokio::test] -async fn get_activity() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/activities/act-1")) - .respond_with(ok_json(serde_json::json!({ - "id": "act-1", - "type": "SERVICE_STOP", - "actorType": "api" - }))) - .mount(&s) - .await; - - let resp = c.activity_get("org-1", "act-1").await.unwrap(); - let activity = resp.result.unwrap(); - assert_eq!(activity.id.as_deref(), Some("act-1")); -} - -// =========================================================================== -// BYOC Infrastructure -// =========================================================================== - -#[tokio::test] -async fn create_byoc_infrastructure() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/byocInfrastructure")) - .and(body_partial_json(serde_json::json!({ - "accountId": "123456789012", - "availabilityZoneSuffixes": ["a", "b"], - "displayName": "My BYOC" - }))) - .respond_with(ok_json(serde_json::json!({ - "id": "byoc-1", - "cloudProvider": "aws", - "displayName": "My BYOC" - }))) - .mount(&s) - .await; - - let body = ByocInfrastructurePostRequest { - account_id: "123456789012".to_string(), - availability_zone_suffixes: Some(vec![ - ByocAvailabilityZoneSuffix::A, - ByocAvailabilityZoneSuffix::B, - ]), - display_name: Some("My BYOC".to_string()), - ..Default::default() - }; - let resp = c - .organization_byoc_infrastructure_create("org-1", &body) - .await - .unwrap(); - let config = resp.result.unwrap(); - assert_eq!(config.display_name.as_deref(), Some("My BYOC")); -} - -#[tokio::test] -async fn create_byoc_infrastructure_sends_byo_vpc_fields_and_tags() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/byocInfrastructure")) - .and(body_json(serde_json::json!({ - "accountId": "123456789012", - "regionId": "us-east-1", - "externalId": "ch-ext", - "vpcId": "vpc-1", - "privateSubnetIds": ["subnet-a", "subnet-b"], - "publicSubnetIds": ["subnet-pub"], - "tags": {"team": "data", "cost-center": "42"} - }))) - .respond_with(ok_json(serde_json::json!({"id": "byoc-1"}))) - .expect(1) - .mount(&s) - .await; - - let body = ByocInfrastructurePostRequest { - account_id: "123456789012".to_string(), - region_id: ByocInfrastructurePostRequestRegionid::Us_east_1, - external_id: Some("ch-ext".to_string()), - vpc_id: Some("vpc-1".to_string()), - private_subnet_ids: Some(vec!["subnet-a".to_string(), "subnet-b".to_string()]), - public_subnet_ids: Some(vec!["subnet-pub".to_string()]), - tags: Some(ByocInfrastructureTags::from([ - ("team".to_string(), "data".to_string()), - ("cost-center".to_string(), "42".to_string()), - ])), - ..Default::default() - }; - let resp = c - .organization_byoc_infrastructure_create("org-1", &body) - .await - .unwrap(); - assert_eq!(resp.result.unwrap().id.as_deref(), Some("byoc-1")); -} - -#[tokio::test] -async fn validate_byoc_infrastructure() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/byocInfrastructure/validate")) - .and(body_json(serde_json::json!({ - "accountId": "123456789012", - "regionId": "eu-west-1", - "availabilityZoneSuffixes": ["a", "b", "c"], - "vpcCidrRange": "10.0.0.0/16", - "externalId": "ch-ext" - }))) - .respond_with(ok_json(serde_json::json!({ - "cloudProvider": "aws", - "allPassed": false, - "anyPassed": true, - "supported": true, - "checks": [ - { - "name": "Create EKS cluster", - "action": "eks:CreateCluster", - "allowed": true, - "group": "base" - }, - { - "name": "Create VPC", - "action": "ec2:CreateVpc", - "allowed": false, - "reason": "implicitDeny", - "group": "vpc-write" - } - ] - }))) - .expect(1) - .mount(&s) - .await; - - let body = ByocInfrastructureValidatePostRequest { - account_id: "123456789012".to_string(), - region_id: ByocInfrastructureValidatePostRequestRegionid::Eu_west_1, - availability_zone_suffixes: Some(vec![ - ByocAvailabilityZoneSuffix::A, - ByocAvailabilityZoneSuffix::B, - ByocAvailabilityZoneSuffix::C, - ]), - vpc_cidr_range: Some("10.0.0.0/16".to_string()), - external_id: Some("ch-ext".to_string()), - ..Default::default() - }; - let resp = c - .organization_byoc_infrastructure_validate("org-1", &body) - .await - .unwrap(); - let validation = resp.result.unwrap(); - assert_eq!( - validation.cloud_provider, - Some(ByocInfrastructureValidationCloudprovider::Aws) - ); - assert_eq!(validation.all_passed, Some(false)); - assert_eq!(validation.any_passed, Some(true)); - assert_eq!(validation.supported, Some(true)); - let checks = validation.checks.unwrap(); - assert_eq!(checks.len(), 2); - assert_eq!(checks[0].action.as_deref(), Some("eks:CreateCluster")); - assert_eq!(checks[0].allowed, Some(true)); - assert_eq!(checks[0].reason, None); - assert_eq!(checks[1].allowed, Some(false)); - assert_eq!(checks[1].reason.as_deref(), Some("implicitDeny")); - assert_eq!(checks[1].group.as_deref(), Some("vpc-write")); -} - -#[tokio::test] -async fn get_byoc_infrastructure() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/byocInfrastructure/byoc-1")) - .respond_with(ok_json(serde_json::json!({ - "id": "byoc-1", - "state": "infra-provisioning", - "accountId": "123456789012", - "regionId": "us-central1", - "cloudProvider": "gcp", - "displayName": "My BYOC", - "enablePrivateLink": true, - "enablePrivateLoadBalancer": false, - "enablePublicLoadBalancer": true, - "vpcAvailabilityZoneList": ["us-central1-a", "us-central1-b"], - "isByoVpc": true, - "byoVpcId": "my-network", - "byoVpcPrivateSubnetIds": ["my-subnet"], - "byoVpcPodCidrRangeNames": ["pods"], - "byoVpcSharedVpcHostProjectId": "host-project", - "gcpPscSubnetId": "psc-subnet" - }))) - .expect(1) - .mount(&s) - .await; - - let resp = c - .organization_byoc_infrastructure_get("org-1", "byoc-1") - .await - .unwrap(); - let details = resp.result.unwrap(); - assert_eq!(details.id.as_deref(), Some("byoc-1")); - assert_eq!( - details.state, - Some(ByocInfrastructureDetailsState::Infra_provisioning) - ); - assert_eq!( - details.region_id, - Some(ByocInfrastructureDetailsRegionid::Us_central1) - ); - assert_eq!( - details.cloud_provider, - Some(ByocInfrastructureDetailsCloudprovider::Gcp) - ); - assert_eq!(details.enable_private_link, Some(true)); - assert_eq!(details.enable_private_load_balancer, Some(false)); - assert_eq!(details.is_byo_vpc, Some(true)); - assert_eq!(details.byo_vpc_id.as_deref(), Some("my-network")); - assert_eq!( - details.byo_vpc_private_subnet_ids, - Some(vec!["my-subnet".to_string()]) - ); - assert_eq!( - details.byo_vpc_shared_vpc_host_project_id.as_deref(), - Some("host-project") - ); - assert_eq!(details.gcp_psc_subnet_id.as_deref(), Some("psc-subnet")); - assert_eq!(details.vpc_cidr_range, None); -} - -#[tokio::test] -async fn get_byoc_infrastructure_progress() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/byocInfrastructure/byoc-1/progress", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "byoc-1", - "status": "in_progress", - "updatedAt": "2026-09-30T12:00:00Z", - "stages": [ - { - "name": "network", - "status": "ready", - "subStages": [{"name": "vpc", "status": "ready"}] - }, - {"name": "cluster", "status": "failed", "message": "quota exceeded"} - ] - }))) - .expect(1) - .mount(&s) - .await; - - let resp = c - .organization_byoc_infrastructure_progress_get("org-1", "byoc-1") - .await - .unwrap(); - let progress = resp.result.unwrap(); - assert_eq!(progress.id.as_deref(), Some("byoc-1")); - assert_eq!( - progress.status, - Some(ByocInfrastructureProgressStatus::InProgress) - ); - assert_eq!( - progress.updated_at.map(|t| t.to_rfc3339()), - Some("2026-09-30T12:00:00+00:00".to_string()) - ); - let stages = progress.stages.unwrap(); - assert_eq!(stages.len(), 2); - let sub = stages[0].sub_stages.as_ref().unwrap(); - assert_eq!(sub[0].name.as_deref(), Some("vpc")); - assert_eq!( - stages[1].status, - Some(ByocInfrastructureProgressStageStatus::Failed) - ); - assert_eq!(stages[1].message.as_deref(), Some("quota exceeded")); -} - -#[tokio::test] -async fn byoc_infrastructure_get_surfaces_api_errors() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/byocInfrastructure/missing/progress", - )) - .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ - "status": 404, - "error": "BYOC infrastructure not found" - }))) - .mount(&s) - .await; - - let err = c - .organization_byoc_infrastructure_progress_get("org-1", "missing") - .await - .unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 404); - assert_eq!(message, "BYOC infrastructure not found"); - } - other => panic!("unexpected error: {other:?}"), - } -} - -#[tokio::test] -async fn update_byoc_infrastructure() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/byocInfrastructure/byoc-1")) - .and(body_partial_json( - serde_json::json!({"displayName": "Renamed BYOC"}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "byoc-1", - "displayName": "Renamed BYOC" - }))) - .mount(&s) - .await; - - let body = ByocInfrastructurePatchRequest { - display_name: Some("Renamed BYOC".to_string()), - ..Default::default() - }; - let resp = c - .organization_byoc_infrastructure_update("org-1", "byoc-1", &body) - .await - .unwrap(); - let config = resp.result.unwrap(); - assert_eq!(config.display_name.as_deref(), Some("Renamed BYOC")); -} - -#[tokio::test] -async fn update_byoc_infrastructure_sends_connectivity_and_tags() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/byocInfrastructure/byoc-1")) - .and(basic_auth("key", "secret")) - .and(body_json(serde_json::json!({ - "enablePrivateLink": true, - "enablePrivateLoadBalancer": false, - "enablePublicLoadBalancer": true, - "gcpPscSubnetId": "psc-subnet", - "tags": {"team": "data"} - }))) - .respond_with(ok_json(serde_json::json!({"id": "byoc-1"}))) - .expect(1) - .mount(&s) - .await; - - let body = ByocInfrastructurePatchRequest { - display_name: None, - enable_private_link: Some(true), - enable_private_load_balancer: Some(false), - enable_public_load_balancer: Some(true), - gcp_psc_subnet_id: Some("psc-subnet".to_string()), - tags: Some(ByocInfrastructureTags::from([( - "team".to_string(), - "data".to_string(), - )])), - }; - let resp = c - .organization_byoc_infrastructure_update("org-1", "byoc-1", &body) - .await - .unwrap(); - assert_eq!(resp.result.unwrap().id.as_deref(), Some("byoc-1")); -} - -#[tokio::test] -async fn get_byoc_infrastructure_private_endpoint_config() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/byocInfrastructure/byoc-1/privateEndpointConfig", - )) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!({ - "endpointName": "com.amazonaws.vpce.us-east-1.vpce-svc-123", - "privateDnsHostname": "byoc-1.private.clickhouse.cloud" - }))) - .expect(1) - .mount(&s) - .await; - - let resp = c - .organization_byoc_infrastructure_private_endpoint_config_get("org-1", "byoc-1") - .await - .unwrap(); - let config = resp.result.unwrap(); - assert_eq!( - config.endpoint_name.as_deref(), - Some("com.amazonaws.vpce.us-east-1.vpce-svc-123") - ); - assert_eq!( - config.private_dns_hostname.as_deref(), - Some("byoc-1.private.clickhouse.cloud") - ); -} - -#[tokio::test] -async fn get_byoc_infrastructure_tags() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/byocInfrastructure/byoc-1/tags", - )) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!({ - "tags": {"team": "data", "env": "prod"} - }))) - .expect(1) - .mount(&s) - .await; - - let resp = c - .organization_byoc_infrastructure_tags_get("org-1", "byoc-1") - .await - .unwrap(); - let tags = resp.result.unwrap().tags.unwrap(); - assert_eq!(tags.len(), 2); - assert_eq!(tags.get("team").map(String::as_str), Some("data")); - assert_eq!(tags.get("env").map(String::as_str), Some("prod")); -} - -#[tokio::test] -async fn byoc_infrastructure_tags_get_surfaces_api_errors() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/byocInfrastructure/missing/tags", - )) - .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ - "status": 404, - "error": "BYOC infrastructure not found" - }))) - .mount(&s) - .await; - - let err = c - .organization_byoc_infrastructure_tags_get("org-1", "missing") - .await - .unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 404); - assert_eq!(message, "BYOC infrastructure not found"); - } - other => panic!("unexpected error: {other:?}"), - } -} - -#[tokio::test] -async fn delete_byoc_infrastructure() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path("/v1/organizations/org-1/byocInfrastructure/byoc-1")) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c - .organization_byoc_infrastructure_delete("org-1", "byoc-1") - .await - .unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// Invitations -// =========================================================================== - -#[tokio::test] -async fn list_invitations() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/invitations")) - .respond_with(ok_json(serde_json::json!([ - { - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "email": "alice@example.com", - "role": "developer" - } - ]))) - .mount(&s) - .await; - - let resp = c.invitation_get_list("org-1").await.unwrap(); - let invitations = resp.result.unwrap(); - assert_eq!(invitations.len(), 1); - assert_eq!(invitations[0].email.as_deref(), Some("alice@example.com")); -} - -#[tokio::test] -async fn create_invitation() { - let mock_server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/invitations")) - .and(body_partial_json( - serde_json::json!({"email": "newuser@example.com"}), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": { - "id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", - "email": "newuser@example.com", - "role": "developer" - } - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let body = InvitationPostRequest { - email: "newuser@example.com".to_string(), - #[cfg(feature = "deprecated-fields")] - role: Some(InvitationPostRequestRole::Developer), - ..Default::default() - }; - let resp = client.invitation_create("org-1", &body).await.unwrap(); - let inv = resp.result.unwrap(); - assert_eq!(inv.email.as_deref(), Some("newuser@example.com")); -} - -#[tokio::test] -async fn get_invitation() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/invitations/inv-1")) - .respond_with(ok_json(serde_json::json!({ - "id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", - "email": "bob@example.com", - "role": "admin" - }))) - .mount(&s) - .await; - - let resp = c.invitation_get("org-1", "inv-1").await.unwrap(); - let inv = resp.result.unwrap(); - assert_eq!(inv.email.as_deref(), Some("bob@example.com")); - #[cfg(feature = "deprecated-fields")] - assert_eq!(inv.role, Some(InvitationRole::Admin)); -} - -#[tokio::test] -async fn delete_invitation() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path("/v1/organizations/org-1/invitations/inv-1")) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c.invitation_delete("org-1", "inv-1").await.unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// API Keys -// =========================================================================== - -#[tokio::test] -async fn list_api_keys() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/keys")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": [ - { - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "Production Key", - "state": "enabled" - } - ] - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let resp = client - .openapi_key_get_list("org-1", None, None) - .await - .unwrap(); - let keys = resp.result.unwrap(); - assert_eq!(keys.len(), 1); - assert_eq!(keys[0].name.as_deref(), Some("Production Key")); -} - -#[tokio::test] -async fn list_api_keys_pagination_encodes_parameters_and_preserves_envelope() { - let (server, client) = setup().await; - let cursor = "opaque +/=&?雪"; - Mock::given(method("GET")) - .and(path("/v1/organizations/org/keys")) - .and(basic_auth("key", "secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200.0, "requestId": "req-page", "result": [], - "limit": 25, "totalCount": 100, "nextCursor": cursor - }))) - .expect(4) - .mount(&server) - .await; - for (limit, cursor_arg) in [ - (None, None), - (Some(25), None), - (None, Some(cursor)), - (Some(25), Some(cursor)), - ] { - let response = client - .openapi_key_get_list("org", limit, cursor_arg) - .await - .unwrap(); - assert_eq!(response.status, Some(200)); - assert_eq!(response.request_id.as_deref(), Some("req-page")); - assert_eq!(response.result, Some(vec![])); - assert_eq!(response.error, None); - assert_eq!(response.limit, Some(25)); - assert_eq!(response.total_count, Some(100)); - assert_eq!(response.next_cursor.as_deref(), Some(cursor)); - } - let requests = server.received_requests().await.unwrap(); - let queries: Vec> = requests - .iter() - .map(|request| request.url.query_pairs().into_owned().collect()) - .collect(); - assert_eq!( - queries, - vec![ - vec![], - vec![("limit".into(), "25".into())], - vec![("cursor".into(), cursor.into())], - vec![ - ("limit".into(), "25".into()), - ("cursor".into(), cursor.into()) - ], - ] - ); -} - -#[tokio::test] -async fn list_organizations_pagination_encodes_parameters_and_preserves_envelope() { - let (server, client) = setup().await; - let cursor = "opaque +/=&?雪"; - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .and(basic_auth("key", "secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200.0, "requestId": "req-page", "result": [], - "limit": 25, "nextCursor": cursor - }))) - .expect(4) - .mount(&server) - .await; - for (limit, cursor_arg) in [ - (None, None), - (Some(25), None), - (None, Some(cursor)), - (Some(25), Some(cursor)), - ] { - let response = client - .organization_get_list(limit, cursor_arg) - .await - .unwrap(); - assert_eq!(response.status, Some(200)); - assert_eq!(response.request_id.as_deref(), Some("req-page")); - assert_eq!(response.result, Some(vec![])); - assert_eq!(response.error, None); - assert_eq!(response.limit, Some(25)); - assert_eq!(response.next_cursor.as_deref(), Some(cursor)); - } - let requests = server.received_requests().await.unwrap(); - let queries: Vec> = requests - .iter() - .map(|request| request.url.query_pairs().into_owned().collect()) - .collect(); - assert_eq!( - queries, - vec![ - vec![], - vec![("limit".into(), "25".into())], - vec![("cursor".into(), cursor.into())], - vec![ - ("limit".into(), "25".into()), - ("cursor".into(), cursor.into()) - ], - ] - ); -} - -#[tokio::test] -async fn list_snapshots_pagination_encodes_parameters_and_preserves_envelope() { - let (server, client) = setup().await; - let cursor = "opaque +/=&?雪"; - Mock::given(method("GET")) - .and(path("/v1/organizations/org/services/svc/snapshots")) - .and(basic_auth("key", "secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200.0, "requestId": "req-page", "result": [], - "limit": 25, "totalCount": 100, "nextCursor": cursor - }))) - .expect(4) - .mount(&server) - .await; - for (limit, cursor_arg) in [ - (None, None), - (Some(25), None), - (None, Some(cursor)), - (Some(25), Some(cursor)), - ] { - let response = client - .snapshot_get_list("org", "svc", limit, cursor_arg) - .await - .unwrap(); - assert_eq!(response.status, Some(200)); - assert_eq!(response.request_id.as_deref(), Some("req-page")); - assert_eq!(response.result, Some(vec![])); - assert_eq!(response.error, None); - assert_eq!(response.limit, Some(25)); - assert_eq!(response.total_count, Some(100)); - assert_eq!(response.next_cursor.as_deref(), Some(cursor)); - } - let requests = server.received_requests().await.unwrap(); - let queries: Vec> = requests - .iter() - .map(|request| request.url.query_pairs().into_owned().collect()) - .collect(); - assert_eq!( - queries, - vec![ - vec![], - vec![("limit".into(), "25".into())], - vec![("cursor".into(), cursor.into())], - vec![ - ("limit".into(), "25".into()), - ("cursor".into(), cursor.into()) - ], - ] - ); -} - -#[tokio::test] -async fn list_api_keys_preserves_api_errors() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org/keys")) - .and(basic_auth("key", "secret")) - .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ - "status": 403.0, "error": "forbidden", "requestId": "req-denied" - }))) - .expect(1) - .mount(&server) - .await; - let error = client - .openapi_key_get_list("org", Some(25), Some("cursor")) - .await - .unwrap_err(); - assert!( - matches!(error, clickhouse_cloud_api::Error::Api { status: 403, message } if message == "forbidden") - ); -} - -#[tokio::test] -async fn create_api_key() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/keys")) - .and(body_partial_json(serde_json::json!({"name": "New Key"}))) - .respond_with(ok_json(serde_json::json!({ - "key": { - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "New Key", - "state": "enabled" - }, - "keyId": "key-id-abc", - "keySecret": "key-secret-xyz" - }))) - .mount(&s) - .await; - - let body = ApiKeyPostRequest { - name: "New Key".to_string(), - ..Default::default() - }; - let resp = c.openapi_key_create("org-1", &body).await.unwrap(); - let result = resp.result.unwrap(); - assert_eq!(result.key_id.as_deref(), Some("key-id-abc")); - assert_eq!(result.key_secret.as_deref(), Some("key-secret-xyz")); - assert_eq!( - result.key.as_ref().and_then(|key| key.name.as_deref()), - Some("New Key") - ); -} - -#[tokio::test] -async fn get_api_key() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/keys/key-1")) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "My Key", - "state": "enabled", - "keySuffix": "abc" - }))) - .mount(&s) - .await; - - let resp = c.openapi_key_get("org-1", "key-1").await.unwrap(); - let key = resp.result.unwrap(); - assert_eq!(key.name.as_deref(), Some("My Key")); - assert_eq!(key.state, Some(ApiKeyState::Enabled)); - assert_eq!(key.key_suffix.as_deref(), Some("abc")); -} - -#[tokio::test] -async fn update_api_key() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/keys/key-1")) - .and(body_partial_json( - serde_json::json!({"name": "Renamed Key"}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "Renamed Key", - "state": "enabled" - }))) - .mount(&s) - .await; - - let body = ApiKeyPatchRequest { - name: Some("Renamed Key".to_string()), - ..Default::default() - }; - let resp = c.openapi_key_update("org-1", "key-1", &body).await.unwrap(); - let key = resp.result.unwrap(); - assert_eq!(key.name.as_deref(), Some("Renamed Key")); -} - -#[tokio::test] -async fn delete_api_key() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path("/v1/organizations/org-1/keys/key-1")) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c.openapi_key_delete("org-1", "key-1").await.unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// Members -// =========================================================================== - -#[tokio::test] -async fn list_members() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/members")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": [ - { - "userId": "user-1", - "name": "Alice", - "email": "alice@example.com", - "role": "admin" - }, - { - "userId": "user-2", - "name": "Bob", - "email": "bob@example.com", - "role": "developer" - } - ] - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let resp = client.member_get_list("org-1").await.unwrap(); - let members = resp.result.unwrap(); - assert_eq!(members.len(), 2); - #[cfg(feature = "deprecated-fields")] - { - assert_eq!(members[0].role, Some(MemberRole::Admin)); - assert_eq!(members[1].role, Some(MemberRole::Developer)); - } -} - -#[tokio::test] -async fn get_member() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/members/user-1")) - .respond_with(ok_json(serde_json::json!({ - "userId": "user-1", - "name": "Alice", - "email": "alice@example.com", - "role": "admin" - }))) - .mount(&s) - .await; - - let resp = c.member_get("org-1", "user-1").await.unwrap(); - let member = resp.result.unwrap(); - assert_eq!(member.name.as_deref(), Some("Alice")); - #[cfg(feature = "deprecated-fields")] - assert_eq!(member.role, Some(MemberRole::Admin)); -} - -#[tokio::test] -async fn update_member() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/members/user-1")) - .respond_with(ok_json(serde_json::json!({ - "userId": "user-1", - "name": "Alice", - "email": "alice@example.com", - "role": "admin" - }))) - .mount(&s) - .await; - - let body = MemberPatchRequest { - #[cfg(feature = "deprecated-fields")] - role: Some(MemberPatchRequestRole::Admin), - ..Default::default() - }; - let resp = c.member_update("org-1", "user-1", &body).await.unwrap(); - let member = resp.result.unwrap(); - assert_eq!(member.email.as_deref(), Some("alice@example.com")); - #[cfg(feature = "deprecated-fields")] - assert_eq!(member.role, Some(MemberRole::Admin)); -} - -#[tokio::test] -async fn delete_member() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path("/v1/organizations/org-1/members/user-1")) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c.member_delete("org-1", "user-1").await.unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// Services (instances) -// =========================================================================== - -#[tokio::test] -async fn list_services() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-123/services")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": [ - { - "id": "11111111-2222-3333-4444-555555555555", - "name": "svc-1", - "provider": "aws", - "region": "us-east-1", - "state": "running", - "tier": "production" - } - ] - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let resp = client.instance_get_list("org-123", &[]).await.unwrap(); - let services = resp.result.unwrap(); - assert_eq!(services.len(), 1); - assert_eq!(services[0].name.as_deref(), Some("svc-1")); - assert_eq!(services[0].provider, Some(ServiceProvider::Aws)); - assert_eq!(services[0].state, Some(ServiceState::Running)); -} - -#[tokio::test] -async fn create_service() { - let mock_server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-123/services")) - .and(body_partial_json( - serde_json::json!({"name": "new-service", "provider": "aws", "region": "us-east-1"}), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": { - "service": { - "id": "22222222-3333-4444-5555-666666666666", - "name": "new-service", - "provider": "aws", - "region": "us-east-1", - "state": "provisioning", - "tier": "production" - }, - "password": "generated-password-123" - } - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let body = ServicePostRequest { - name: "new-service".to_string(), - provider: ServicePostRequestProvider::Aws, - region: ServicePostRequestRegion::Us_east_1, - #[cfg(feature = "deprecated-fields")] - tier: Some(ServicePostRequestTier::Production), - ..Default::default() - }; - let resp = client.instance_create("org-123", &body).await.unwrap(); - let result = resp.result.unwrap(); - assert_eq!(result.password.as_deref(), Some("generated-password-123")); -} - -#[tokio::test] -async fn get_service_details() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": { - "id": "11111111-2222-3333-4444-555555555555", - "name": "prod-service", - "provider": "gcp", - "region": "us-east1", - "state": "running", - "tier": "production", - "numReplicas": 3, - "endpoints": [ - { - "protocol": "https", - "host": "abc.clickhouse.cloud", - "port": 8443 - } - ] - } - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let resp = client.instance_get("org-1", "svc-1").await.unwrap(); - let svc = resp.result.unwrap(); - assert_eq!(svc.name.as_deref(), Some("prod-service")); - assert_eq!(svc.provider, Some(ServiceProvider::Gcp)); - assert_eq!(svc.num_replicas, Some(3)); -} - -#[tokio::test] -async fn update_service() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/services/svc-1")) - .and(body_partial_json( - serde_json::json!({"name": "renamed-svc"}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "11111111-2222-3333-4444-555555555555", - "name": "renamed-svc", - "state": "running" - }))) - .mount(&s) - .await; - - let body = ServicePatchRequest { - name: Some("renamed-svc".to_string()), - ..Default::default() - }; - let resp = c.instance_update("org-1", "svc-1", &body).await.unwrap(); - let svc = resp.result.unwrap(); - assert_eq!(svc.name.as_deref(), Some("renamed-svc")); -} - -#[tokio::test] -async fn delete_service() { - let mock_server = MockServer::start().await; - - Mock::given(method("DELETE")) - .and(path("/v1/organizations/org-123/services/svc-456")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "requestId": "req-del-123" - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let resp = client.instance_delete("org-123", "svc-456").await.unwrap(); - assert_eq!(resp.status, Some(200)); -} - -#[tokio::test] -async fn update_service_state() { - let mock_server = MockServer::start().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-123/services/svc-456/state")) - .and(body_partial_json(serde_json::json!({"command": "stop"}))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": { - "id": "11111111-2222-3333-4444-555555555555", - "name": "my-svc", - "state": "stopping" - } - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let body = ServiceStatePatchRequest { - command: Some(ServiceStatePatchRequestCommand::Stop), - }; - let resp = client - .instance_state_update("org-123", "svc-456", &body) - .await - .unwrap(); - let svc = resp.result.unwrap(); - assert_eq!(svc.state, Some(ServiceState::Stopping)); -} - -#[tokio::test] -async fn update_service_password() { - let mock_server = MockServer::start().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/services/svc-1/password")) - .and(body_partial_json(serde_json::json!({}))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": { - "password": "new-password-abc" - } - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let body = ServicePasswordPatchRequest { - ..Default::default() - }; - let resp = client - .instance_password_update("org-1", "svc-1", &body) - .await - .unwrap(); - let result = resp.result.unwrap(); - assert_eq!(result.password.as_deref(), Some("new-password-abc")); -} - -// =========================================================================== -// Service sub-resources: scaling, private endpoints, query endpoints, prometheus -// =========================================================================== - -#[tokio::test] -async fn update_replica_scaling() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/services/svc-1/replicaScaling")) - .and(body_partial_json(serde_json::json!({"numReplicas": 5, "minReplicaMemoryGb": 16.0, "maxReplicaMemoryGb": 64.0}))) - .respond_with(ok_json(serde_json::json!({ - "id": "11111111-2222-3333-4444-555555555555", - "name": "svc-1", - "numReplicas": 5, - "minReplicaMemoryGb": 16, - "maxReplicaMemoryGb": 64 - }))) - .mount(&s) - .await; - - let body = ServiceReplicaScalingPatchRequest { - num_replicas: Some(5), - min_replica_memory_gb: Some(16.0), - max_replica_memory_gb: Some(64.0), - ..Default::default() - }; - let resp = c - .instance_replica_scaling_update("org-1", "svc-1", &body) - .await - .unwrap(); - let result = resp.result.unwrap(); - assert_eq!(result.num_replicas, Some(5)); -} - -#[tokio::test] -#[allow(deprecated)] -async fn update_scaling_deprecated() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/services/svc-1/scaling")) - .and(body_partial_json(serde_json::json!({"numReplicas": 3}))) - .respond_with(ok_json(serde_json::json!({ - "id": "11111111-2222-3333-4444-555555555555", - "name": "svc-1", - "numReplicas": 3 - }))) - .mount(&s) - .await; - - let body = ServiceScalingPatchRequest { - num_replicas: Some(3), - ..Default::default() - }; - let resp = c - .instance_scaling_update("org-1", "svc-1", &body) - .await - .unwrap(); - let svc = resp.result.unwrap(); - assert_eq!(svc.num_replicas, Some(3)); -} - -#[tokio::test] -async fn create_private_endpoint() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/privateEndpoint", - )) - .and(body_partial_json( - serde_json::json!({"id": "vpce-abc", "description": "My PE"}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "pe-1", - "description": "My PE" - }))) - .mount(&s) - .await; - - let body = ServicPrivateEndpointePostRequest { - id: "vpce-abc".to_string(), - description: Some("My PE".to_string()), - }; - let resp = c - .instance_private_endpoint_create("org-1", "svc-1", &body) - .await - .unwrap(); - let pe = resp.result.unwrap(); - assert_eq!(pe.description.as_deref(), Some("My PE")); -} - -#[tokio::test] -async fn get_private_endpoint_config_for_service() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/privateEndpointConfig", - )) - .respond_with(ok_json(serde_json::json!({ - "endpointServiceId": "vpce-svc-abc", - "privateDnsHostname": "svc-1.private.clickhouse.cloud" - }))) - .mount(&s) - .await; - - let resp = c - .instance_private_endpoint_config_get("org-1", "svc-1") - .await - .unwrap(); - let config = resp.result.unwrap(); - assert_eq!(config.endpoint_service_id.as_deref(), Some("vpce-svc-abc")); - assert_eq!( - config.private_dns_hostname.as_deref(), - Some("svc-1.private.clickhouse.cloud") - ); -} - -#[tokio::test] -async fn get_service_prometheus_metrics() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/prometheus")) - .respond_with( - ResponseTemplate::new(200).set_body_string("# HELP svc_metric\nsvc_metric 100\n"), - ) - .mount(&s) - .await; - - let resp = c - .instance_prometheus_get("org-1", "svc-1", None) - .await - .unwrap(); - assert!(resp.contains("svc_metric")); -} - -#[tokio::test] -async fn get_service_prometheus_with_filter() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/prometheus")) - .and(query_param("filtered_metrics", "cpu,memory")) - .respond_with(ResponseTemplate::new(200).set_body_string("cpu 42\nmemory 1024\n")) - .mount(&s) - .await; - - let resp = c - .instance_prometheus_get("org-1", "svc-1", Some("cpu,memory")) - .await - .unwrap(); - assert!(resp.contains("cpu 42")); -} - -#[tokio::test] -async fn get_query_endpoint() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/serviceQueryEndpoint", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "qe-1", - "allowedOrigins": "*", - "roles": ["sql_console_admin"] - }))) - .mount(&s) - .await; - - let resp = c - .instance_query_endpoint_get("org-1", "svc-1") - .await - .unwrap(); - let qe = resp.result.unwrap(); - assert_eq!(qe.id.as_deref(), Some("qe-1")); - assert_eq!(qe.allowed_origins.as_deref(), Some("*")); - assert_eq!(qe.roles, Some(vec![QueryEndpointRole::SqlConsoleAdmin])); -} - -#[tokio::test] -async fn upsert_query_endpoint() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/serviceQueryEndpoint", - )) - .and(body_partial_json(serde_json::json!({ - "allowedOrigins": "https://example.com", - "roles": ["sql_console_read_only"] - }))) - .respond_with(ok_json(serde_json::json!({ - "id": "qe-1", - "allowedOrigins": "https://example.com", - "roles": ["sql_console_read_only"] - }))) - .mount(&s) - .await; - - let body = InstanceServiceQueryApiEndpointsPostRequest { - allowed_origins: Some("https://example.com".to_string()), - roles: vec![QueryEndpointRole::SqlConsoleReadOnly], - ..Default::default() - }; - let resp = c - .instance_query_endpoint_upsert("org-1", "svc-1", &body) - .await - .unwrap(); - let qe = resp.result.unwrap(); - assert_eq!(qe.allowed_origins.as_deref(), Some("https://example.com")); -} - -#[tokio::test] -async fn delete_query_endpoint() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/services/svc-1/serviceQueryEndpoint", - )) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c - .instance_query_endpoint_delete("org-1", "svc-1") - .await - .unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// Backups & Backup Configuration -// =========================================================================== - -#[tokio::test] -async fn get_snapshot_configuration() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", - )) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!({ - "enabled": true, "gap": 30, "timeFrame": 1440 - }))) - .expect(1) - .mount(&server) - .await; - - let configuration = client - .snapshot_configuration_get("org-1", "svc-1") - .await - .unwrap() - .result - .unwrap(); - assert_eq!(configuration.enabled, Some(true)); - assert_eq!(configuration.gap, Some(30.0)); - assert_eq!(configuration.time_frame, Some(1440.0)); -} - -#[tokio::test] -async fn update_snapshot_configuration_sends_supported_presets() { - for (gap, time_frame) in [(30.0, 1440.0), (60.0, 2880.0)] { - let (server, client) = setup().await; - let wire = serde_json::json!({ - "enabled": true, "gap": gap, "timeFrame": time_frame - }); - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", - )) - .and(basic_auth("key", "secret")) - .and(body_json(wire.clone())) - .respond_with(ok_json(wire)) - .expect(1) - .mount(&server) - .await; - - let configuration = client - .snapshot_configuration_update( - "org-1", - "svc-1", - &SnapshotConfigurationPatchRequest { - enabled: Some(true), - gap: Some(gap), - time_frame: Some(time_frame), - }, - ) - .await - .unwrap() - .result - .unwrap(); - assert_eq!(configuration.enabled, Some(true)); - assert_eq!(configuration.gap, Some(gap)); - assert_eq!(configuration.time_frame, Some(time_frame)); - } -} - -#[tokio::test] -async fn update_snapshot_configuration_omits_unchanged_fields() { - let (server, client) = setup().await; - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", - )) - .and(basic_auth("key", "secret")) - .and(body_json(serde_json::json!({"enabled": false}))) - .respond_with(ok_json(serde_json::json!({"enabled": false}))) - .expect(1) - .mount(&server) - .await; - - let configuration = client - .snapshot_configuration_update( - "org-1", - "svc-1", - &SnapshotConfigurationPatchRequest { - enabled: Some(false), - ..Default::default() - }, - ) - .await - .unwrap() - .result - .unwrap(); - assert_eq!(configuration.enabled, Some(false)); - assert_eq!(configuration.gap, None); - assert_eq!(configuration.time_frame, None); -} - -#[tokio::test] -async fn update_snapshot_configuration_leaves_validation_to_the_server() { - for (request, wire, message) in [ - ( - SnapshotConfigurationPatchRequest::default(), - serde_json::json!({}), - "Provide at least one field", - ), - ( - SnapshotConfigurationPatchRequest { - enabled: Some(true), - gap: Some(0.0), - time_frame: Some(0.0), - }, - serde_json::json!({"enabled": true, "gap": 0.0, "timeFrame": 0.0}), - "Unsupported snapshot cadence", - ), - ] { - let (server, client) = setup().await; - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", - )) - .and(basic_auth("key", "secret")) - .and(body_json(wire)) - .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ - "status": 400, "error": message - }))) - .expect(1) - .mount(&server) - .await; - - let error = client - .snapshot_configuration_update("org-1", "svc-1", &request) - .await - .unwrap_err(); - assert!( - matches!(error, clickhouse_cloud_api::Error::Api { status: 400, message: actual } if actual == message) - ); - } -} - -#[tokio::test] -async fn snapshot_configuration_methods_preserve_api_errors() { - for (status, response, message) in [ - ( - 403, - ResponseTemplate::new(403).set_body_json(serde_json::json!({ - "status": 403, "error": "Access denied" - })), - "Access denied", - ), - ( - 500, - ResponseTemplate::new(500).set_body_string("Snapshot configuration unavailable"), - "Snapshot configuration unavailable", - ), - ] { - let (server, client) = setup().await; - Mock::given(path( - "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", - )) - .respond_with(response) - .expect(2) - .mount(&server) - .await; - - let errors = [ - client - .snapshot_configuration_get("org-1", "svc-1") - .await - .unwrap_err(), - client - .snapshot_configuration_update( - "org-1", - "svc-1", - &SnapshotConfigurationPatchRequest { - enabled: Some(false), - ..Default::default() - }, - ) - .await - .unwrap_err(), - ]; - for error in errors { - assert!( - matches!(error, clickhouse_cloud_api::Error::Api { status: actual_status, message: actual_message } if actual_status == status && actual_message == message) - ); - } - } -} - -#[tokio::test] -async fn snapshot_configuration_methods_reject_malformed_success_payloads() { - let (server, client) = setup().await; - Mock::given(path( - "/v1/organizations/org-1/services/svc-1/snapshotConfiguration", - )) - .respond_with(ResponseTemplate::new(200).set_body_string("not JSON")) - .expect(2) - .mount(&server) - .await; - - assert!(matches!( - client.snapshot_configuration_get("org-1", "svc-1").await, - Err(clickhouse_cloud_api::Error::Json(_)) - )); - assert!(matches!( - client - .snapshot_configuration_update( - "org-1", - "svc-1", - &SnapshotConfigurationPatchRequest { - enabled: Some(false), - ..Default::default() - }, - ) - .await, - Err(clickhouse_cloud_api::Error::Json(_)) - )); -} - -#[tokio::test] -async fn list_snapshots() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/snapshots")) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!([ - {"status": "throttled", "type": "full", "durationInSeconds": 1.5}, - {"status": "done", "type": "full", "sizeInBytes": 2048} - ]))) - .expect(1) - .mount(&server) - .await; - - let snapshots = client - .snapshot_get_list("org-1", "svc-1", None, None) - .await - .unwrap() - .result - .unwrap(); - assert_eq!(snapshots.len(), 2); - assert_eq!(snapshots[0].status, Some(SnapshotStatus::Throttled)); - assert_eq!(snapshots[0].r#type, Some(SnapshotType::Full)); - assert_eq!(snapshots[0].duration_in_seconds, Some(1.5)); - assert_eq!(snapshots[1].status, Some(SnapshotStatus::Done)); -} - -#[tokio::test] -async fn get_snapshot() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/snapshots/snapshot-1", - )) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "serviceId": "svc-1", - "status": "done", - "type": "full", - "sizeInBytes": 2048, - "bucket": {"bucketProvider": "AWS", "bucketPath": "s3://backups"} - }))) - .expect(1) - .mount(&server) - .await; - - let snapshot = client - .snapshot_get("org-1", "svc-1", "snapshot-1") - .await - .unwrap() - .result - .unwrap(); - assert_eq!( - snapshot.id.unwrap().to_string(), - "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - ); - assert_eq!(snapshot.service_id.as_deref(), Some("svc-1")); - assert_eq!(snapshot.size_in_bytes, Some(2048.0)); - assert_eq!(snapshot.bucket.unwrap()["bucketPath"], "s3://backups"); -} - -#[tokio::test] -async fn snapshot_methods_preserve_api_errors() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/snapshots")) - .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ - "status": 403, "error": "Access denied" - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/snapshots/missing", - )) - .respond_with(ResponseTemplate::new(500).set_body_string("Snapshot lookup failed")) - .expect(1) - .mount(&server) - .await; - - let list_error = client - .snapshot_get_list("org-1", "svc-1", None, None) - .await - .unwrap_err(); - assert!( - matches!(list_error, clickhouse_cloud_api::Error::Api { status: 403, message } if message == "Access denied") - ); - let get_error = client - .snapshot_get("org-1", "svc-1", "missing") - .await - .unwrap_err(); - assert!( - matches!(get_error, clickhouse_cloud_api::Error::Api { status: 500, message } if message == "Snapshot lookup failed") - ); -} - -#[tokio::test] -async fn snapshot_methods_reject_malformed_success_payloads() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .respond_with(ResponseTemplate::new(200).set_body_string("not JSON")) - .expect(2) - .mount(&server) - .await; - assert!(matches!( - client.snapshot_get_list("org-1", "svc-1", None, None).await, - Err(clickhouse_cloud_api::Error::Json(_)) - )); - assert!(matches!( - client.snapshot_get("org-1", "svc-1", "snapshot-1").await, - Err(clickhouse_cloud_api::Error::Json(_)) - )); -} - -#[tokio::test] -async fn list_backups() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/backups")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": [ - { - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "status": "done", - "serviceId": "svc-1", - "startedAt": "2024-06-01T00:00:00Z", - "finishedAt": "2024-06-01T00:05:00Z", - "sizeInBytes": 1024, - "durationInSeconds": 300, - "type": "full" - } - ] - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let resp = client.backup_get_list("org-1", "svc-1").await.unwrap(); - let backups = resp.result.unwrap(); - assert_eq!(backups.len(), 1); - assert_eq!(backups[0].status, Some(BackupStatus::Done)); - assert_eq!(backups[0].r#type, Some(BackupType::Full)); -} - -#[tokio::test] -async fn get_single_backup() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/backups/bak-1")) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "status": "done", - "sizeInBytes": 2048, - "type": "full" - }))) - .mount(&s) - .await; - - let resp = c.backup_get("org-1", "svc-1", "bak-1").await.unwrap(); - let backup = resp.result.unwrap(); - assert_eq!(backup.status, Some(BackupStatus::Done)); - assert_eq!(backup.size_in_bytes, Some(2048.0)); -} - -#[tokio::test] -async fn get_backup_configuration() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/backupConfiguration", - )) - .respond_with(ok_json(serde_json::json!({ - "backupPeriodInHours": 24, - "backupRetentionPeriodInHours": 168, - "backupStartTime": "02:00" - }))) - .mount(&s) - .await; - - let resp = c.backup_configuration_get("org-1", "svc-1").await.unwrap(); - let config = resp.result.unwrap(); - assert_eq!(config.backup_period_in_hours, Some(24.0)); - assert_eq!(config.backup_start_time.as_deref(), Some("02:00")); -} - -#[tokio::test] -async fn update_backup_configuration() { - let mock_server = MockServer::start().await; - - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/backupConfiguration", - )) - .and(body_partial_json(serde_json::json!({"backupPeriodInHours": 12.0, "backupRetentionPeriodInHours": 336.0, "backupStartTime": "03:00"}))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": { - "backupPeriodInHours": 12, - "backupRetentionPeriodInHours": 336, - "backupStartTime": "03:00" - } - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "key", "secret"); - let body = BackupConfigurationPatchRequest { - backup_period_in_hours: Some(12.0), - backup_retention_period_in_hours: Some(336.0), - backup_start_time: Some(Some("03:00".to_string())), - }; - let resp = client - .backup_configuration_update("org-1", "svc-1", &body) - .await - .unwrap(); - let config = resp.result.unwrap(); - assert_eq!(config.backup_period_in_hours, Some(12.0)); -} - -// =========================================================================== -// Backup Buckets -// =========================================================================== - -#[tokio::test] -async fn get_backup_bucket() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) - .respond_with(ok_json(serde_json::json!({ - "bucketPath": "s3://my-backup-bucket/prefix", - "bucketProvider": "AWS", - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "iamRoleArn": "arn:aws:iam::123:role/backup-role", - "iamRoleExternalId": "trust-policy-id" - }))) - .mount(&s) - .await; - - let resp = c.backup_bucket_get("org-1", "svc-1").await.unwrap(); - let Some(BackupBucket::AwsBackupBucket(bucket)) = resp.result else { - panic!("expected AWS bucket"); - }; - assert_eq!( - bucket.iam_role_external_id.as_deref(), - Some("trust-policy-id") - ); -} - -#[tokio::test] -async fn create_backup_bucket() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) - .and(body_partial_json( - serde_json::json!({"bucketPath": "s3://new-bucket", "iamRoleExternalId": "trust-policy-id"}), - )) - .respond_with(ok_json(serde_json::json!({ - "bucketPath": "s3://new-bucket", - "bucketProvider": "AWS", - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - }))) - .mount(&s) - .await; - - let body = - BackupBucketPostRequest::AwsBackupBucketPostRequestV1(AwsBackupBucketPostRequestV1 { - bucket_path: "s3://new-bucket".to_string(), - iam_role_external_id: "trust-policy-id".to_string(), - ..Default::default() - }); - let resp = c - .backup_bucket_create("org-1", "svc-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn update_backup_bucket() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) - .and(body_partial_json( - serde_json::json!({"bucketPath": "s3://updated-bucket", "iamRoleExternalId": null}), - )) - .respond_with(ok_json(serde_json::json!({ - "bucketPath": "s3://updated-bucket", - "bucketProvider": "AWS", - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - }))) - .mount(&s) - .await; - - let body = - BackupBucketPatchRequest::AwsBackupBucketPatchRequestV1(AwsBackupBucketPatchRequestV1 { - bucket_path: "s3://updated-bucket".to_string(), - iam_role_external_id: Some(None), - ..Default::default() - }); - let resp = c - .backup_bucket_update("org-1", "svc-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn delete_backup_bucket() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path("/v1/organizations/org-1/services/svc-1/backupBucket")) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c.backup_bucket_delete("org-1", "svc-1").await.unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// ClickPipes -// =========================================================================== - -#[tokio::test] -async fn list_click_pipes() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/clickpipes")) - .respond_with(ok_json(serde_json::json!([ - { - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "kafka-pipe", - "state": "Running" - } - ]))) - .mount(&s) - .await; - - let resp = c.click_pipe_get_list("org-1", "svc-1").await.unwrap(); - let pipes = resp.result.unwrap(); - assert_eq!(pipes.len(), 1); - assert_eq!(pipes[0].name.as_deref(), Some("kafka-pipe")); -} - -/// Mirror the shape the live API actually returns for a Kafka pipe — including -/// `reversePrivateEndpointIds: null`, which the spec declares as a required -/// array but the server happily emits as null when unset. Response fields are -/// `Option`, so `null` lands as `None` rather than failing with -/// `invalid type: null, expected a sequence`. -#[tokio::test] -async fn list_click_pipes_with_null_array_fields() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/clickpipes")) - .respond_with(ok_json(serde_json::json!([ - { - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "serviceId": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", - "name": "kafka-pipe", - "state": "Running", - "scaling": { - "replicas": 1, - "replicaCpuMillicores": 125, - "replicaMemoryGb": 0.5 - }, - "source": { - "kafka": { - "type": "confluent", - "format": "JSONEachRow", - "brokers": "broker.example:9092", - "topics": "events", - "consumerGroup": "clickpipes-aaaaaaaa", - "authentication": "PLAIN", - "reversePrivateEndpointIds": null - } - }, - "destination": { - "database": "default", - "table": "events", - "managedTable": true, - "tableDefinition": { - "engine": {"type": "MergeTree"}, - "sortingKey": ["id"] - }, - "columns": [] - }, - "fieldMappings": [], - "settings": {}, - "createdAt": "2026-05-13T17:47:28.132987Z", - "updatedAt": "2026-05-13T17:47:28.132987Z" - } - ]))) - .mount(&s) - .await; - - let resp = c.click_pipe_get_list("org-1", "svc-1").await.unwrap(); - let pipes = resp.result.unwrap(); - assert_eq!(pipes.len(), 1); - assert_eq!(pipes[0].name.as_deref(), Some("kafka-pipe")); - let kafka = pipes[0] - .source - .as_ref() - .and_then(|source| source.kafka.as_ref()) - .expect("kafka source present"); - assert_eq!(kafka.reverse_private_endpoint_ids, None); -} - -#[tokio::test] -async fn create_click_pipe() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/services/svc-1/clickpipes")) - .and(body_partial_json(serde_json::json!({"name": "new-pipe"}))) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "new-pipe", - "state": "Provisioning" - }))) - .mount(&s) - .await; - - let body = ClickPipePostRequest { - name: "new-pipe".to_string(), - ..Default::default() - }; - let resp = c.click_pipe_create("org-1", "svc-1", &body).await.unwrap(); - let pipe = resp.result.unwrap(); - assert_eq!(pipe.name.as_deref(), Some("new-pipe")); -} - -#[tokio::test] -async fn create_kinesis_click_pipe_sends_glue_registry() { - let (server, client) = setup().await; - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/services/svc-1/clickpipes")) - .and(body_partial_json(serde_json::json!({ - "source": {"kinesis": {"schemaRegistry": { - "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events" - }}} - }))) - .respond_with(ok_json(serde_json::json!({"name": "kinesis-pipe"}))) - .expect(1) - .mount(&server) - .await; - let request = ClickPipePostRequest { - name: "kinesis-pipe".into(), - source: ClickPipePostSource { - kinesis: Some(ClickPipePostKinesisSource { - schema_registry: Some(ClickPipeKinesisSchemaRegistry { - r#type: ClickPipeKinesisSchemaRegistryType::Glue, - glue_region: "us-east-1".into(), - glue_registry_name: "events".into(), - glue_role_arn: None, - }), - ..Default::default() - }), - ..Default::default() - }, - ..Default::default() - }; - let response = client - .click_pipe_create("org-1", "svc-1", &request) - .await - .unwrap(); - assert_eq!( - response.result.unwrap().name.as_deref(), - Some("kinesis-pipe") - ); -} - -#[tokio::test] -async fn get_click_pipe_preserves_nullable_kinesis_registry() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1", - )) - .respond_with(ok_json(serde_json::json!({ - "source": {"kinesis": {"schemaRegistry": { - "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": null - }}} - }))) - .mount(&server) - .await; - let pipe = client - .click_pipe_get("org-1", "svc-1", "pipe-1") - .await - .unwrap() - .result - .unwrap(); - let registry = pipe - .source - .unwrap() - .kinesis - .unwrap() - .schema_registry - .unwrap(); - assert_eq!(registry.glue_region.as_deref(), Some("us-east-1")); - assert_eq!(registry.glue_registry_name, None); - assert_eq!( - ClickPipeKinesisSchemaRegistry::try_from(registry) - .unwrap_err() - .fields(), - &["glueRegistryName"] - ); -} - -#[tokio::test] -async fn create_click_pipe_sends_start_paused_and_table_ttl() { - let (server, client) = setup().await; - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/services/svc-1/clickpipes")) - .and(body_partial_json(serde_json::json!({ - "startPaused": true, - "destination": {"tableDefinition": {"ttl": "event_time + INTERVAL 30 DAY"}} - }))) - .respond_with(ok_json(serde_json::json!({"state": "Stopped"}))) - .expect(1) - .mount(&server) - .await; - let request = ClickPipePostRequest { - start_paused: Some(true), - destination: ClickPipeMutateDestination { - table_definition: Some(ClickPipeDestinationTableDefinition { - ttl: "event_time + INTERVAL 30 DAY".to_string(), - ..Default::default() - }), - ..Default::default() - }, - ..Default::default() - }; - let response = client - .click_pipe_create("org-1", "svc-1", &request) - .await - .unwrap(); - assert_eq!( - response.result.unwrap().state, - Some(ClickPipeState::Stopped) - ); -} - -#[tokio::test] -async fn get_click_pipe() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "my-pipe", - "state": "Running" - }))) - .mount(&s) - .await; - - let resp = c.click_pipe_get("org-1", "svc-1", "pipe-1").await.unwrap(); - let pipe = resp.result.unwrap(); - assert_eq!(pipe.name.as_deref(), Some("my-pipe")); -} - -#[tokio::test] -async fn update_click_pipe() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1", - )) - .and(body_partial_json( - serde_json::json!({"name": "renamed-pipe"}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "renamed-pipe", - "state": "Running" - }))) - .mount(&s) - .await; - - let body = ClickPipePatchRequest { - name: Some("renamed-pipe".to_string()), - ..Default::default() - }; - let resp = c - .click_pipe_update("org-1", "svc-1", "pipe-1", &body) - .await - .unwrap(); - let pipe = resp.result.unwrap(); - assert_eq!(pipe.name.as_deref(), Some("renamed-pipe")); -} - -#[tokio::test] -async fn delete_click_pipe() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1", - )) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c - .click_pipe_delete("org-1", "svc-1", "pipe-1") - .await - .unwrap(); - assert_eq!(resp.status, Some(200)); -} - -#[tokio::test] -async fn update_click_pipe_state() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1/state", - )) - .and(body_partial_json(serde_json::json!({"command": "stop"}))) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "my-pipe", - "state": "Stopped" - }))) - .mount(&s) - .await; - - let body = ClickPipeStatePatchRequest { - command: Some(ClickPipeStatePatchRequestCommand::Stop), - }; - let resp = c - .click_pipe_state_update("org-1", "svc-1", "pipe-1", &body) - .await - .unwrap(); - let pipe = resp.result.unwrap(); - assert_eq!(pipe.state, Some(ClickPipeState::Stopped)); -} - -#[tokio::test] -async fn update_click_pipe_scaling() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1/scaling", - )) - .and(body_partial_json(serde_json::json!({}))) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "my-pipe", - "state": "Running" - }))) - .mount(&s) - .await; - - let body = ClickPipeScalingPatchRequest { - ..Default::default() - }; - let resp = c - .click_pipe_scaling_update("org-1", "svc-1", "pipe-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn get_click_pipe_settings() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1/settings", - )) - .respond_with(ok_json(serde_json::json!({}))) - .mount(&s) - .await; - - let resp = c - .click_pipe_settings_get("org-1", "svc-1", "pipe-1") - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn update_click_pipe_settings() { - let (s, c) = setup().await; - - Mock::given(method("PUT")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipes/pipe-1/settings", - )) - .and(body_partial_json(serde_json::json!({}))) - .respond_with(ok_json(serde_json::json!({}))) - .mount(&s) - .await; - - let body = ClickPipeSettingsPutRequest { - kafka_read_committed: Some(true), - ..Default::default() - }; - let resp = c - .click_pipe_settings_update("org-1", "svc-1", "pipe-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -// =========================================================================== -// ClickPipes Schema Discovery (Beta) -// =========================================================================== - -#[tokio::test] -async fn click_pipe_schema_discovery_kafka() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipes/schemaDiscovery", - )) - .and(body_partial_json(serde_json::json!({ - "source": {"kafka": {"brokers": "broker1:9092"}} - }))) - .respond_with(ok_json(serde_json::json!({ - "fields": [ - {"name": "user_id", "type": "Int64", "optional": false}, - {"name": "event", "type": "String", "optional": true} - ] - }))) - .mount(&s) - .await; - - let body = ClickPipeSchemaDiscoveryRequest { - source: ClickPipeSchemaDiscoverySource { - kafka: Some(ClickPipePostKafkaSource { - brokers: "broker1:9092".to_string(), - ..Default::default() - }), - kinesis: None, - object_storage: None, - pubsub: None, - }, - }; - let resp = c - .click_pipe_schema_discovery("org-1", "svc-1", &body) - .await - .unwrap(); - let result = resp.result.unwrap(); - let fields = result.fields.expect("fields should populate"); - assert_eq!(fields.len(), 2); - assert_eq!(fields[0].name.as_deref(), Some("user_id")); - assert_eq!(fields[0].r#type.as_deref(), Some("Int64")); - assert_eq!(fields[1].optional, Some(true)); -} - -// =========================================================================== -// ClickPipes CDC Scaling -// =========================================================================== - -#[tokio::test] -async fn get_cdc_scaling() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipesCdcScaling", - )) - .respond_with(ok_json(serde_json::json!({ - "replicaCpuMillicores": 2000, - "replicaMemoryGb": 8.0 - }))) - .mount(&s) - .await; - - let resp = c - .click_pipe_cdc_scaling_get("org-1", "svc-1") - .await - .unwrap(); - let scaling = resp.result.unwrap(); - assert_eq!(scaling.replica_cpu_millicores, Some(2000)); - assert_eq!(scaling.replica_memory_gb, Some(8.0)); -} - -#[tokio::test] -async fn update_cdc_scaling() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipesCdcScaling", - )) - .and(body_partial_json( - serde_json::json!({"replicaCpuMillicores": 4000, "replicaMemoryGb": 16.0}), - )) - .respond_with(ok_json(serde_json::json!({ - "replicaCpuMillicores": 4000, - "replicaMemoryGb": 16.0 - }))) - .mount(&s) - .await; - - let body = ClickPipesCdcScalingPatchRequest { - replica_cpu_millicores: Some(4000), - replica_memory_gb: Some(16.0), - }; - let resp = c - .click_pipe_cdc_scaling_update("org-1", "svc-1", &body) - .await - .unwrap(); - let scaling = resp.result.unwrap(); - assert_eq!(scaling.replica_cpu_millicores, Some(4000)); -} - -// =========================================================================== -// ClickPipes Reverse Private Endpoints -// =========================================================================== - -#[tokio::test] -async fn list_reverse_private_endpoints() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipesReversePrivateEndpoints", - )) - .respond_with(ok_json(serde_json::json!([ - { - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "description": "MSK endpoint", - "status": "available" - } - ]))) - .mount(&s) - .await; - - let resp = c - .click_pipe_reverse_private_endpoint_get_list("org-1", "svc-1") - .await - .unwrap(); - let endpoints = resp.result.unwrap(); - assert_eq!(endpoints.len(), 1); - assert_eq!(endpoints[0].description.as_deref(), Some("MSK endpoint")); -} - -#[tokio::test] -async fn create_reverse_private_endpoint() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipesReversePrivateEndpoints", - )) - .and(body_partial_json( - serde_json::json!({"description": "New RPE"}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "description": "New RPE", - "status": "creating" - }))) - .mount(&s) - .await; - - let body = CreateReversePrivateEndpoint { - description: "New RPE".to_string(), - ..Default::default() - }; - let resp = c - .click_pipe_reverse_private_endpoint_create("org-1", "svc-1", &body) - .await - .unwrap(); - let rpe = resp.result.unwrap(); - assert_eq!(rpe.description.as_deref(), Some("New RPE")); -} - -#[tokio::test] -async fn get_reverse_private_endpoint() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipesReversePrivateEndpoints/rpe-1", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "description": "My RPE", - "status": "available" - }))) - .mount(&s) - .await; - - let resp = c - .click_pipe_reverse_private_endpoint_get("org-1", "svc-1", "rpe-1") - .await - .unwrap(); - let rpe = resp.result.unwrap(); - assert_eq!(rpe.description.as_deref(), Some("My RPE")); -} - -#[tokio::test] -async fn delete_reverse_private_endpoint() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickpipesReversePrivateEndpoints/rpe-1", - )) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c - .click_pipe_reverse_private_endpoint_delete("org-1", "svc-1", "rpe-1") - .await - .unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// ClickStack: Alerts -// =========================================================================== - -#[tokio::test] -async fn list_alerts() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/alerts", - )) - .respond_with(ok_json(serde_json::json!([ - { - "id": "alert-1", - "name": "High CPU" - } - ]))) - .mount(&s) - .await; - - let resp = c - .click_stack_list_alerts("org-1", "svc-1", None, None) - .await - .unwrap(); - let alerts = resp.result.unwrap(); - assert_eq!(alerts.len(), 1); -} - -#[tokio::test] -async fn create_alert() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/alerts", - )) - .and(body_partial_json(serde_json::json!({"name": "New Alert"}))) - .respond_with(ok_json(serde_json::json!({ - "id": "alert-1", - "name": "New Alert" - }))) - .mount(&s) - .await; - - let body = ClickStackCreateAlertRequest { - name: Some("New Alert".to_string()), - ..Default::default() - }; - let resp = c - .click_stack_create_alert("org-1", "svc-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn create_inline_alert_sends_chart_config_and_reads_it_back() { - let (s, c) = setup().await; - let chart_config = serde_json::json!({ - "displayType": "number", "configType": "sql", "connectionId": "conn-1", - "sqlTemplate": "SELECT count() FROM otel_logs", "name": "Error count" - }); - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/alerts", - )) - .and(body_json(serde_json::json!({ - "source": "inline", "chartConfig": chart_config, "threshold": 5.0, - "interval": "5m", "thresholdType": "above", "displayName": "Errors", - "tags": ["p1"] - }))) - .respond_with(ok_json(serde_json::json!({ - "id": "alert-1", "source": "inline", "chartConfig": chart_config, - "displayName": "Errors", "tags": ["p1"] - }))) - .mount(&s) - .await; - - let body = ClickStackCreateAlertRequest { - source: Some(ClickStackCreateAlertRequestSource::Inline), - chart_config: Some( - ClickStackAlertChartConfig::ClickStackAlertNumberChartConfig( - ClickStackAlertNumberChartConfig::ClickStackAlertNumberRawSqlChartConfig( - ClickStackAlertNumberRawSqlChartConfig { - connection_id: "conn-1".into(), - sql_template: "SELECT count() FROM otel_logs".into(), - name: Some("Error count".into()), - ..Default::default() - }, - ), - ), - ), - threshold: 5.0, - interval: ClickStackCreateAlertRequestInterval::_5m, - display_name: Some("Errors".into()), - tags: Some(vec!["p1".into()]), - ..Default::default() - }; - let alert = c - .click_stack_create_alert("org-1", "svc-1", &body) - .await - .unwrap() - .result - .unwrap(); - assert_eq!(alert.source, Some(ClickStackAlertResponseSource::Inline)); - assert_eq!(alert.display_name.as_deref(), Some("Errors")); - assert!(matches!( - alert.chart_config, - Some( - ClickStackAlertChartConfigResponse::ClickStackAlertNumberChartConfig( - ClickStackAlertNumberChartConfigResponse::ClickStackAlertNumberRawSqlChartConfig(_) - ) - ) - )); -} - -#[tokio::test] -async fn get_alert() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/alerts/alert-1", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "alert-1", - "name": "My Alert" - }))) - .mount(&s) - .await; - - let resp = c - .click_stack_get_alert("org-1", "svc-1", "alert-1") - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn update_alert() { - let (s, c) = setup().await; - - Mock::given(method("PUT")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/alerts/alert-1", - )) - .and(body_partial_json( - serde_json::json!({"name": "Updated Alert"}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "alert-1", - "name": "Updated Alert" - }))) - .mount(&s) - .await; - - let body = ClickStackUpdateAlertRequest { - name: Some("Updated Alert".to_string()), - ..Default::default() - }; - let resp = c - .click_stack_update_alert("org-1", "svc-1", "alert-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn delete_alert() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/alerts/alert-1", - )) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c - .click_stack_delete_alert("org-1", "svc-1", "alert-1") - .await - .unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// ClickStack: Saved Searches -// =========================================================================== - -#[tokio::test] -async fn list_saved_searches() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/saved-searches", - )) - .respond_with(ok_json(serde_json::json!([ - { - "id": "search-1", - "name": "Production Errors", - "sourceId": "source-1" - } - ]))) - .mount(&s) - .await; - - let resp = c - .click_stack_list_saved_searches("org-1", "svc-1", None, None) - .await - .unwrap(); - let searches = resp.result.unwrap(); - assert_eq!(searches.len(), 1); - assert_eq!(searches[0].source_id.as_deref(), Some("source-1")); -} - -#[tokio::test] -async fn create_saved_search() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/saved-searches", - )) - .and(body_partial_json(serde_json::json!({ - "name": "Production Errors", - "sourceId": "source-1" - }))) - .respond_with(ok_json(serde_json::json!({ - "id": "search-1", - "name": "Production Errors", - "sourceId": "source-1" - }))) - .mount(&s) - .await; - - let body = ClickStackSavedSearchInput { - name: "Production Errors".to_string(), - source_id: "source-1".to_string(), - ..Default::default() - }; - let resp = c - .click_stack_create_saved_search("org-1", "svc-1", &body) - .await - .unwrap(); - assert_eq!(resp.result.unwrap().id.as_deref(), Some("search-1")); -} - -#[tokio::test] -async fn get_saved_search() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/saved-searches/search-1", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "search-1", - "name": "Production Errors", - "sourceId": "source-1" - }))) - .mount(&s) - .await; - - let resp = c - .click_stack_get_saved_search("org-1", "svc-1", "search-1") - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn update_saved_search() { - let (s, c) = setup().await; - - Mock::given(method("PUT")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/saved-searches/search-1", - )) - .and(body_partial_json(serde_json::json!({ - "name": "Updated Search", - "sourceId": "source-1" - }))) - .respond_with(ok_json(serde_json::json!({ - "id": "search-1", - "name": "Updated Search", - "sourceId": "source-1" - }))) - .mount(&s) - .await; - - let body = ClickStackSavedSearchInput { - name: "Updated Search".to_string(), - source_id: "source-1".to_string(), - ..Default::default() - }; - let resp = c - .click_stack_update_saved_search("org-1", "svc-1", "search-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn delete_saved_search() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/saved-searches/search-1", - )) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c - .click_stack_delete_saved_search("org-1", "svc-1", "search-1") - .await - .unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// ClickStack: Dashboards -// =========================================================================== - -#[tokio::test] -async fn list_dashboards() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/dashboards", - )) - .respond_with(ok_json(serde_json::json!([ - { - "id": "dash-1", - "name": "Overview" - } - ]))) - .mount(&s) - .await; - - let resp = c - .click_stack_list_dashboards("org-1", "svc-1") - .await - .unwrap(); - let dashboards = resp.result.unwrap(); - assert_eq!(dashboards.len(), 1); -} - -#[tokio::test] -async fn create_dashboard() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/dashboards", - )) - .and(body_partial_json( - serde_json::json!({"name": "New Dashboard", "tiles": []}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "dash-new", - "name": "New Dashboard" - }))) - .mount(&s) - .await; - - let body = ClickStackCreateDashboardRequest { - name: "New Dashboard".to_string(), - tiles: vec![], - ..Default::default() - }; - let resp = c - .click_stack_create_dashboard("org-1", "svc-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn get_dashboard() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/dashboards/dash-1", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "dash-1", - "name": "My Dashboard" - }))) - .mount(&s) - .await; - - let resp = c - .click_stack_get_dashboard("org-1", "svc-1", "dash-1") - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn update_dashboard() { - let (s, c) = setup().await; - - Mock::given(method("PUT")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/dashboards/dash-1", - )) - .and(body_partial_json( - serde_json::json!({"name": "Updated Dashboard", "tiles": []}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "dash-1", - "name": "Updated Dashboard" - }))) - .mount(&s) - .await; - - let body = ClickStackUpdateDashboardRequest { - name: "Updated Dashboard".to_string(), - tiles: vec![], - ..Default::default() - }; - let resp = c - .click_stack_update_dashboard("org-1", "svc-1", "dash-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn delete_dashboard() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/dashboards/dash-1", - )) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c - .click_stack_delete_dashboard("org-1", "svc-1", "dash-1") - .await - .unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// ClickStack: Sources & Webhooks -// =========================================================================== - -#[tokio::test] -async fn list_sources() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/sources", - )) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - let resp = c.click_stack_list_sources("org-1", "svc-1").await.unwrap(); - let sources = resp.result.unwrap(); - assert_eq!(sources.len(), 0); -} - -#[tokio::test] -async fn create_source() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/sources", - )) - .and(body_partial_json( - serde_json::json!({"kind": "promql", "name": "Prometheus Metrics"}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "source-1", - "kind": "promql", - "name": "Prometheus Metrics", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "metrics"}, - "timestampValueExpression": "timestamp" - }))) - .mount(&s) - .await; - - let body = ClickStackSource::ClickStackPromqlSource(ClickStackPromqlSource { - name: "Prometheus Metrics".to_string(), - kind: ClickStackPromqlSourceKind::Promql, - connection: "conn-1".to_string(), - from: ClickStackSourceFrom { - database_name: "default".to_string(), - table_name: "metrics".to_string(), - }, - timestamp_value_expression: "timestamp".to_string(), - ..Default::default() - }); - let resp = c - .click_stack_create_source("org-1", "svc-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn get_source() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/sources/source-1", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "source-1", - "kind": "promql", - "name": "My Source", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "metrics"}, - "timestampValueExpression": "timestamp" - }))) - .mount(&s) - .await; - - let resp = c - .click_stack_get_source("org-1", "svc-1", "source-1") - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn update_source() { - let (s, c) = setup().await; - - Mock::given(method("PUT")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/sources/source-1", - )) - .and(body_partial_json( - serde_json::json!({"name": "Updated Source"}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "source-1", - "kind": "promql", - "name": "Updated Source", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "metrics"}, - "timestampValueExpression": "timestamp" - }))) - .mount(&s) - .await; - - let body = ClickStackSource::ClickStackPromqlSource(ClickStackPromqlSource { - name: "Updated Source".to_string(), - kind: ClickStackPromqlSourceKind::Promql, - connection: "conn-1".to_string(), - from: ClickStackSourceFrom { - database_name: "default".to_string(), - table_name: "metrics".to_string(), - }, - timestamp_value_expression: "timestamp".to_string(), - ..Default::default() - }); - let resp = c - .click_stack_update_source("org-1", "svc-1", "source-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn delete_source() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/sources/source-1", - )) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c - .click_stack_delete_source("org-1", "svc-1", "source-1") - .await - .unwrap(); - assert_eq!(resp.status, Some(200)); -} - -#[tokio::test] -async fn list_webhooks() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/webhooks", - )) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - let resp = c - .click_stack_list_webhooks("org-1", "svc-1", None, None) - .await - .unwrap(); - let webhooks = resp.result.unwrap(); - assert_eq!(webhooks.len(), 0); -} - -// =========================================================================== -// UDFs -// =========================================================================== - -#[tokio::test] -async fn delete_udf() { - let (s, c) = setup().await; - Mock::given(method("DELETE")) - .and(path("/v1/organizations/org-1/udfs/my_udf")) - .respond_with(ok_empty()) - .mount(&s) - .await; - - assert_eq!( - c.udf_delete("org-1", "my_udf").await.unwrap().status, - Some(200) - ); -} - -#[tokio::test] -async fn detach_udf() { - let (s, c) = setup().await; - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/udfs/my_udf/attachments/svc-1", - )) - .respond_with(ok_empty()) - .mount(&s) - .await; - - assert_eq!( - c.udf_detach("org-1", "my_udf", "svc-1") - .await - .unwrap() - .status, - Some(200) - ); -} - -#[tokio::test] -async fn delete_udf_version() { - let (s, c) = setup().await; - Mock::given(method("DELETE")) - .and(path("/v1/organizations/org-1/udfs/my_udf/versions/7")) - .respond_with(ok_empty()) - .mount(&s) - .await; - - assert_eq!( - c.udf_version_delete("org-1", "my_udf", 7) - .await - .unwrap() - .status, - Some(200) - ); -} - -#[tokio::test] -async fn list_udfs_encodes_pagination() { - let (s, c) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/udfs")) - .and(query_param("cursor", "next-page")) - .and(query_param("limit", "25")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - assert!( - c.udf_list("org-1", Some("next-page"), Some(25)) - .await - .unwrap() - .result - .unwrap() - .is_empty() - ); -} - -#[tokio::test] -async fn get_udf() { - let (s, c) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/udfs/my_udf")) - .respond_with(ok_json(serde_json::json!({"functionName": "my_udf"}))) - .mount(&s) - .await; - - assert_eq!( - c.udf_get("org-1", "my_udf") - .await - .unwrap() - .result - .unwrap() - .function_name - .as_deref(), - Some("my_udf") - ); -} - -#[tokio::test] -async fn list_udf_attachments_encodes_pagination() { - let (s, c) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/udfs/my_udf/attachments")) - .and(query_param("cursor", "next-page")) - .and(query_param("limit", "25")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - assert!( - c.udf_attachment_list("org-1", "my_udf", Some("next-page"), Some(25)) - .await - .unwrap() - .result - .unwrap() - .is_empty() - ); -} - -#[tokio::test] -async fn get_udf_attachment() { - let (s, c) = setup().await; - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/udfs/my_udf/attachments/svc-1", - )) - .respond_with(ok_json(serde_json::json!({"serviceId": "svc-1"}))) - .mount(&s) - .await; - - assert_eq!( - c.udf_attachment_get("org-1", "my_udf", "svc-1") - .await - .unwrap() - .result - .unwrap() - .service_id - .as_deref(), - Some("svc-1") - ); -} - -#[tokio::test] -async fn list_udf_versions_encodes_pagination() { - let (s, c) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/udfs/my_udf/versions")) - .and(query_param("cursor", "next-page")) - .and(query_param("limit", "25")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - assert!( - c.udf_version_list("org-1", "my_udf", Some("next-page"), Some(25)) - .await - .unwrap() - .result - .unwrap() - .is_empty() - ); -} - -#[tokio::test] -async fn udf_list_pages_decode_arrays_and_nullable_outer_metadata() { - for endpoint in ["", "/my_udf/versions", "/my_udf/attachments"] { - for results in [ - serde_json::json!([]), - serde_json::json!([{ - "functionName":"my_udf", "serviceId":"svc-1", "version":7, - "status":"future-status", "runtime":null, "futureField":true - }]), - ] { - for metadata in [ - serde_json::json!({"limit":1,"totalCount":3,"nextCursor":"page /+?"}), - serde_json::json!({"limit":null,"totalCount":null,"nextCursor":null}), - serde_json::json!({}), - ] { - let (server, client) = setup().await; - let mut wire = metadata.clone(); - wire["result"] = results.clone(); - wire["status"] = 200.into(); - wire["requestId"] = "udf-page".into(); - Mock::given(method("GET")) - .and(path(format!("/v1/organizations/org-1/udfs{endpoint}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(wire)) - .expect(1) - .mount(&server) - .await; - let decoded = match endpoint { - "" => serde_json::to_value(client.udf_list("org-1", None, None).await.unwrap()) - .unwrap(), - "/my_udf/versions" => serde_json::to_value( - client - .udf_version_list("org-1", "my_udf", None, None) - .await - .unwrap(), - ) - .unwrap(), - _ => serde_json::to_value( - client - .udf_attachment_list("org-1", "my_udf", None, None) - .await - .unwrap(), - ) - .unwrap(), - }; - assert_eq!( - decoded["result"].as_array().unwrap().len(), - results.as_array().unwrap().len() - ); - assert_eq!(decoded["status"], 200); - assert_eq!(decoded["requestId"], "udf-page"); - for field in ["limit", "totalCount", "nextCursor"] { - assert_eq!(decoded[field], metadata[field]); - if metadata[field].is_null() { - assert!(decoded.get(field).is_none()); - } - } - if !results.as_array().unwrap().is_empty() { - assert_eq!(decoded["result"][0]["version"], 7); - assert_eq!(decoded["result"][0]["status"], "future-status"); - assert!(decoded["result"][0].get("futureField").is_none()); - } - } - } - } -} - -#[tokio::test] -async fn create_udf_upload_session() { - let (s, c) = setup().await; - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/udfUploads/url")) - .respond_with(created_json(serde_json::json!({"uploadId": "upload-1"}))) - .mount(&s) - .await; - - assert_eq!( - c.udf_upload_session_create("org-1") - .await - .unwrap() - .result - .unwrap() - .upload_id - .as_deref(), - Some("upload-1") - ); -} - -#[tokio::test] -async fn create_udf_encodes_request_body() { - let (s, c) = setup().await; - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/udfs")) - .and(body_partial_json(serde_json::json!({ - "arguments": [], - "functionName": "my_udf", - "returnType": "String", - "runtime": "python3.11", - "type": "executable", - "uploadId": "upload-1", "deterministic": false, "memoryLimitMib": 256 - }))) - .respond_with(created_json(serde_json::json!({"functionName": "my_udf"}))) - .mount(&s) - .await; - - let body = UdfCreateRequest::UdfCreateRequestV1(UdfCreateRequestV1 { - deterministic: Some(false), - memory_limit_mib: Some(256), - arguments: vec![], - function_name: "my_udf".to_string(), - return_type: "String".to_string(), - runtime: UdfRuntime::Python3_11, - r#type: UdfCreateRequestV1Type::Executable, - upload_id: "upload-1".to_string(), - ..Default::default() - }); - assert_eq!( - c.udf_create("org-1", &body) - .await - .unwrap() - .result - .unwrap() - .function_name - .as_deref(), - Some("my_udf") - ); -} - -#[tokio::test] -async fn create_udf_version_encodes_request_body() { - let (s, c) = setup().await; - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/udfs/my_udf/versions")) - .and(body_partial_json(serde_json::json!({ - "arguments": [], - "returnType": "String", - "runtime": "python3.11", - "type": "executable", - "uploadId": "upload-1", "deterministic": false, "memoryLimitMib": 256 - }))) - .respond_with(created_json(serde_json::json!({"functionName": "my_udf"}))) - .mount(&s) - .await; - - let body = UdfVersionCreateRequest::UdfVersionCreateRequestV1(UdfVersionCreateRequestV1 { - deterministic: Some(false), - memory_limit_mib: Some(256), - arguments: vec![], - return_type: "String".to_string(), - runtime: UdfRuntime::Python3_11, - r#type: UdfVersionCreateRequestV1Type::Executable, - upload_id: "upload-1".to_string(), - ..Default::default() - }); - assert_eq!( - c.udf_version_create("org-1", "my_udf", &body) - .await - .unwrap() - .result - .unwrap() - .function_name - .as_deref(), - Some("my_udf") - ); -} - -#[tokio::test] -async fn attach_udf_encodes_optional_version() { - let (s, c) = setup().await; - let attachment_path = "/v1/organizations/org-1/udfs/my_udf/attachments/svc-1"; - let attachment = serde_json::json!({"functionName": "my_udf", "serviceId": "svc-1"}); - - Mock::given(method("PUT")) - .and(path(attachment_path)) - .and(body_json(serde_json::json!({}))) - .respond_with(ok_json(attachment.clone())) - .mount(&s) - .await; - Mock::given(method("PUT")) - .and(path(attachment_path)) - .and(body_json(serde_json::json!({"version": 7}))) - .respond_with(ok_json(attachment)) - .mount(&s) - .await; - - assert_eq!( - c.udf_attach("org-1", "my_udf", "svc-1", None) - .await - .unwrap() - .result - .unwrap() - .service_id - .as_deref(), - Some("svc-1") - ); - assert_eq!( - c.udf_attach("org-1", "my_udf", "svc-1", Some(7)) - .await - .unwrap() - .result - .unwrap() - .function_name - .as_deref(), - Some("my_udf") - ); -} - -#[tokio::test] -async fn attach_udf_preserves_structured_dependency_errors() { - for (wire_code, expected_code, state, can_wake) in [ - ( - "SERVICE_IDLE", - UdfAttachErrorCode::ServiceIdle, - "idle", - true, - ), - ( - "SERVICE_STOPPED", - UdfAttachErrorCode::ServiceStopped, - "stopped", - false, - ), - ( - "SERVICE_NOT_RUNNING", - UdfAttachErrorCode::ServiceNotRunning, - "starting", - false, - ), - ( - "FUTURE_CODE", - UdfAttachErrorCode::Unknown("FUTURE_CODE".into()), - "future_state", - false, - ), - ] { - let (server, client) = setup().await; - let body = serde_json::json!({ - "error": "service unavailable", "code": wire_code, "serviceState": state, - "canWake": can_wake, "status": 424, - "requestId": "00000000-0000-0000-0000-000000000001", "futureField": 1 - }); - Mock::given(method("PUT")) - .and(path( - "/v1/organizations/org-1/udfs/my_udf/attachments/svc-1", - )) - .and(basic_auth("key", "secret")) - .respond_with(ResponseTemplate::new(424).set_body_json(&body)) - .expect(1) - .mount(&server) - .await; - let error = client - .udf_attach("org-1", "my_udf", "svc-1", None) - .await - .unwrap_err(); - assert_eq!( - error.to_string(), - "API error (status 424): service unavailable" - ); - let clickhouse_cloud_api::Error::UdfAttachmentUnavailable { - status, - message, - response, - } = error - else { - panic!("expected typed UDF dependency error"); - }; - assert_eq!(status, 424); - assert_eq!(message, "service unavailable"); - assert_eq!(response.code, Some(expected_code)); - assert_eq!(response.service_state.as_ref().unwrap().to_string(), state); - assert_eq!(response.can_wake, Some(can_wake)); - let mut expected = body; - expected.as_object_mut().unwrap().remove("futureField"); - assert_eq!(serde_json::to_value(response).unwrap(), expected); - server.verify().await; - } -} - -#[tokio::test] -async fn attach_udf_dependency_errors_tolerate_absent_and_null_fields() { - for body in [ - serde_json::json!({}), - serde_json::json!({ - "error": null, "code": null, "serviceState": null, "canWake": null, - "status": null, "requestId": null - }), - ] { - let (server, client) = setup().await; - Mock::given(method("PUT")) - .respond_with(ResponseTemplate::new(424).set_body_json(body)) - .expect(1) - .mount(&server) - .await; - let error = client - .udf_attach("org-1", "my_udf", "svc-1", None) - .await - .unwrap_err(); - let clickhouse_cloud_api::Error::UdfAttachmentUnavailable { response, .. } = error else { - panic!("expected tolerant UDF dependency error"); - }; - assert_eq!(*response, UdfAttachResponse424::default()); - assert_eq!( - serde_json::to_value(response).unwrap(), - serde_json::json!({}) - ); - } -} - -#[tokio::test] -async fn attach_udf_keeps_generic_errors_for_other_statuses_and_invalid_bodies() { - for (status, body, expected_message) in [ - (424, "upstream unavailable", "upstream unavailable"), - (424, r#"{"error":"bad response","code":42}"#, "bad response"), - ( - 403, - r#"{"error":"forbidden","code":"SERVICE_IDLE"}"#, - "forbidden", - ), - ( - 500, - r#"{"error":"failed","code":"SERVICE_STOPPED"}"#, - "failed", - ), - ] { - let (server, client) = setup().await; - Mock::given(method("PUT")) - .respond_with(ResponseTemplate::new(status).set_body_string(body)) - .expect(1) - .mount(&server) - .await; - let error = client - .udf_attach("org-1", "my_udf", "svc-1", None) - .await - .unwrap_err(); - assert!( - matches!(error, clickhouse_cloud_api::Error::Api { status: actual, message } - if actual == status && message == expected_message) - ); - } -} - -// =========================================================================== -// ClickStack: Roles -// =========================================================================== - -#[tokio::test] -async fn list_roles() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/roles", - )) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - let resp = c.click_stack_list_roles("org-1", "svc-1").await.unwrap(); - let roles = resp.result.unwrap(); - assert_eq!(roles.len(), 0); -} - -#[tokio::test] -async fn create_role() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/roles", - )) - .and(body_partial_json(serde_json::json!({ - "name": "Deploy Bot", - "permissions": [{ - "action": "read", - "subject": "Dashboard", - "integration": "mongodb", - "conditions": { "team": "team-1" } - }] - }))) - .respond_with(ok_json(serde_json::json!({ - "id": "role-1", - "name": "Deploy Bot", - "isPredefined": false, - "permissions": [{ - "action": "read", - "subject": "Dashboard", - "integration": "mongodb", - "conditions": { "team": "team-1" } - }] - }))) - .mount(&s) - .await; - - let body = ClickStackCreateRoleRequest { - name: "Deploy Bot".to_string(), - permissions: vec![ClickStackCASLPermission::ClickStackMongoDBCASLPermission( - ClickStackMongoDBCASLPermission { - action: ClickStackCASLPermissionAction::Read, - subject: ClickStackCASLPermissionSubject::Dashboard, - conditions: Some( - [( - "team".to_string(), - ClickStackMongoDBCASLPermissionConditionValue::String("team-1".to_string()), - )] - .into(), - ), - ..Default::default() - }, - )], - ..Default::default() - }; - let resp = c - .click_stack_create_role("org-1", "svc-1", &body) - .await - .unwrap(); - let role = resp.result.unwrap(); - assert_eq!(role.id.as_deref(), Some("role-1")); - assert_eq!(role.name.as_deref(), Some("Deploy Bot")); - assert_eq!(role.is_predefined, Some(false)); - assert!(matches!( - role.permissions.as_deref(), - Some([ClickStackCASLPermissionResponse::ClickStackMongoDBCASLPermission(_)]) - )); -} - -#[tokio::test] -async fn get_role() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/roles/role-1", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "role-1", - "name": "Read Only", - "isPredefined": true, - "permissions": [{ "action": "read", "subject": "Dashboard", "integration": "mongodb" }] - }))) - .mount(&s) - .await; - - let resp = c - .click_stack_get_role("org-1", "svc-1", "role-1") - .await - .unwrap(); - let role = resp.result.unwrap(); - assert_eq!(role.id.as_deref(), Some("role-1")); - assert_eq!(role.is_predefined, Some(true)); -} - -#[tokio::test] -async fn update_role() { - let (s, c) = setup().await; - - Mock::given(method("PUT")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/roles/role-1", - )) - .and(body_partial_json(serde_json::json!({ - "permissions": [{ "action": "manage", "subject": "all", "integration": "clickhouse" }] - }))) - .respond_with(ok_json(serde_json::json!({ - "id": "role-1", - "name": "Deploy Bot", - "isPredefined": false, - "permissions": [{ "action": "manage", "subject": "all", "integration": "clickhouse" }] - }))) - .mount(&s) - .await; - - let body = ClickStackUpdateRoleRequest { - permissions: vec![ - ClickStackCASLPermission::ClickStackClickHouseCASLPermission( - ClickStackClickHouseCASLPermission { - action: ClickStackCASLPermissionAction::Manage, - subject: ClickStackCASLPermissionSubject::All, - ..Default::default() - }, - ), - ], - ..Default::default() - }; - let resp = c - .click_stack_update_role("org-1", "svc-1", "role-1", &body) - .await - .unwrap(); - let role = resp.result.unwrap(); - let permissions = role.permissions.unwrap_or_default(); - assert_eq!(permissions.len(), 1); - let ClickStackCASLPermissionResponse::ClickStackClickHouseCASLPermission(permission) = - &permissions[0] - else { - panic!("expected the ClickHouse variant, got {:?}", permissions[0]); - }; - assert_eq!( - permission.action, - Some(ClickStackCASLPermissionAction::Manage) - ); -} - -#[tokio::test] -async fn delete_role() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/roles/role-1", - )) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c - .click_stack_delete_role("org-1", "svc-1", "role-1") - .await - .unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// ClickStack: Webhooks & Dashboard validation -// =========================================================================== - -#[tokio::test] -async fn create_webhook() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/webhooks", - )) - .and(body_partial_json(serde_json::json!({ - "name": "Production Alerts", - "service": "slack", - "url": "https://hooks.slack.com/services/T/B/X" - }))) - .respond_with(ok_json(serde_json::json!({ - "id": "webhook-1", - "name": "Production Alerts", - "service": "slack", - "url": "https://hooks.slack.com/services/T/B/X", - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-06-15T10:30:00.000Z" - }))) - .mount(&s) - .await; - - let body = ClickStackWebhookInput { - name: "Production Alerts".to_string(), - service: ClickStackWebhookInputService::Slack, - url: "https://hooks.slack.com/services/T/B/X".to_string(), - ..Default::default() - }; - let resp = c - .click_stack_create_webhook("org-1", "svc-1", &body) - .await - .unwrap(); - assert!(resp.result.is_some()); -} - -#[tokio::test] -async fn update_webhook() { - let (s, c) = setup().await; - - Mock::given(method("PUT")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/webhook-1", - )) - .and(body_partial_json(serde_json::json!({ - "name": "Updated Alerts" - }))) - .respond_with(ok_json(serde_json::json!({ - "id": "webhook-1", - "name": "Updated Alerts", - "service": "slack", - "url": "https://hooks.slack.com/services/T/B/X", - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-06-15T10:30:00.000Z" - }))) - .mount(&s) - .await; - - let body = ClickStackWebhookInput { - name: "Updated Alerts".to_string(), - service: ClickStackWebhookInputService::Slack, - url: "https://hooks.slack.com/services/T/B/X".to_string(), - ..Default::default() - }; - let resp = c - .click_stack_update_webhook("org-1", "svc-1", "webhook-1", &body) - .await - .unwrap(); - // The response union resolves to a concrete Slack webhook variant. - match resp.result.unwrap() { - ClickStackWebhook::ClickStackSlackWebhook(w) => { - assert_eq!(w.id.as_deref(), Some("webhook-1")); - assert_eq!(w.name.as_deref(), Some("Updated Alerts")); - } - other => panic!("expected Slack webhook variant, got {other}"), - } -} - -#[tokio::test] -async fn create_webhook_incidentio_response() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/webhooks", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "webhook-2", - "name": "Incident Alerts", - "service": "incidentio", - "url": "https://api.incident.io/v2/alert_events/http/abc", - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-06-15T10:30:00.000Z" - }))) - .mount(&s) - .await; - - let body = ClickStackWebhookInput { - name: "Incident Alerts".to_string(), - service: ClickStackWebhookInputService::Incidentio, - url: "https://api.incident.io/v2/alert_events/http/abc".to_string(), - ..Default::default() - }; - let resp = c - .click_stack_create_webhook("org-1", "svc-1", &body) - .await - .unwrap(); - // The response union resolves to a concrete IncidentIO webhook variant - // rather than greedily matching the structurally-identical Slack variant. - match resp.result.unwrap() { - ClickStackWebhook::ClickStackIncidentIOWebhook(w) => { - assert_eq!(w.id.as_deref(), Some("webhook-2")); - assert_eq!(w.name.as_deref(), Some("Incident Alerts")); - } - other => panic!("expected IncidentIO webhook variant, got {other}"), - } -} - -#[tokio::test] -async fn update_webhook_generic_response() { - let (s, c) = setup().await; - - Mock::given(method("PUT")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/webhook-3", - )) - .respond_with(ok_json(serde_json::json!({ - "id": "webhook-3", - "name": "Generic Alerts", - "service": "generic", - "url": "https://example.com/hook", - "body": "{\"text\": \"{{ message }}\"}", - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-06-15T10:30:00.000Z" - }))) - .mount(&s) - .await; - - let body = ClickStackWebhookInput { - name: "Generic Alerts".to_string(), - service: ClickStackWebhookInputService::Generic, - url: "https://example.com/hook".to_string(), - ..Default::default() - }; - let resp = c - .click_stack_update_webhook("org-1", "svc-1", "webhook-3", &body) - .await - .unwrap(); - // The response union resolves to the Generic variant and preserves its - // optional `body` field, which the greedy Slack match would have discarded. - match resp.result.unwrap() { - ClickStackWebhook::ClickStackGenericWebhook(w) => { - assert_eq!(w.id.as_deref(), Some("webhook-3")); - assert_eq!(w.body.as_deref(), Some("{\"text\": \"{{ message }}\"}")); - } - other => panic!("expected Generic webhook variant, got {other}"), - } -} - -#[tokio::test] -async fn delete_webhook() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/webhook-1", - )) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c - .click_stack_delete_webhook("org-1", "svc-1", "webhook-1") - .await - .unwrap(); - assert_eq!(resp.status, Some(200)); -} - -#[tokio::test] -async fn validate_dashboard() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/dashboards/validate", - )) - .and(body_partial_json(serde_json::json!({ - "name": "My Dashboard" - }))) - .respond_with(ok_json(serde_json::json!({ - "valid": false, - "errors": [ - {"path": "tiles.0.config", "message": "Required"} - ], - "normalized": null - }))) - .mount(&s) - .await; - - let body = ClickStackCreateDashboardRequest { - name: "My Dashboard".to_string(), - ..Default::default() - }; - let resp = c - .click_stack_validate_dashboard("org-1", "svc-1", &body) - .await - .unwrap(); - let result = resp.result.unwrap(); - assert_eq!(result.valid, Some(false)); - let errors = result.errors.as_ref().expect("errors should populate"); - assert_eq!(errors.len(), 1); - assert_eq!(errors[0].path.as_deref(), Some("tiles.0.config")); - assert_eq!(result.normalized, None); -} - -#[tokio::test] -async fn validate_webhook() { - let (s, c) = setup().await; - - // The full body is matched, so optional fields left as `None` must be - // omitted from the request rather than sent as `null`. - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/validate", - )) - .and(body_json(serde_json::json!({ - "name": "Generic Alerts", - "service": "generic", - "url": "ftp://example.com/hook", - "body": "{{title}}" - }))) - .respond_with(ok_json(serde_json::json!({ - "valid": false, - "errors": [ - {"path": "url", "message": "Invalid url"} - ], - "normalized": null - }))) - .mount(&s) - .await; - - let body = ClickStackWebhookInput { - name: "Generic Alerts".to_string(), - service: ClickStackWebhookInputService::Generic, - url: "ftp://example.com/hook".to_string(), - body: Some("{{title}}".to_string()), - ..Default::default() - }; - let resp = c - .click_stack_validate_webhook("org-1", "svc-1", &body) - .await - .unwrap(); - let result = resp.result.unwrap(); - assert_eq!(result.valid, Some(false)); - let errors = result.errors.as_ref().expect("errors should populate"); - assert_eq!(errors.len(), 1); - assert_eq!(errors[0].path.as_deref(), Some("url")); - assert_eq!(errors[0].message.as_deref(), Some("Invalid url")); - assert_eq!(result.normalized, None); -} - -#[tokio::test] -async fn validate_webhook_returns_the_normalized_webhook() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/validate", - )) - .respond_with(ok_json(serde_json::json!({ - "valid": true, - "errors": [], - "normalized": { - "name": "Production Alerts", - "service": "slack", - "url": "https://hooks.slack.com/services/T/B/X" - } - }))) - .mount(&s) - .await; - - let body = ClickStackWebhookInput { - name: "Production Alerts".to_string(), - service: ClickStackWebhookInputService::Slack, - url: "https://hooks.slack.com/services/T/B/X".to_string(), - ..Default::default() - }; - let resp = c - .click_stack_validate_webhook("org-1", "svc-1", &body) - .await - .unwrap(); - let result = resp.result.unwrap(); - assert_eq!(result.valid, Some(true)); - assert_eq!(result.errors.as_deref(), Some(&[][..])); - let normalized = result.normalized.expect("normalized should populate"); - assert_eq!(normalized["service"], "slack"); -} - -#[tokio::test] -async fn validate_webhook_surfaces_a_bad_request() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickstack/webhooks/validate", - )) - .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ - "status": 400, - "error": "Invalid request body" - }))) - .mount(&s) - .await; - - let err = c - .click_stack_validate_webhook("org-1", "svc-1", &ClickStackWebhookInput::default()) - .await - .expect_err("a 400 must fail the call"); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 400); - assert_eq!(message, "Invalid request body"); - } - other => panic!("expected Error::Api, got {other:?}"), - } -} - -// =========================================================================== -// Organization quotas -// =========================================================================== - -#[tokio::test] -async fn list_quotas() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/quotas")) - .respond_with(ok_json(serde_json::json!([ - { - "quotaCode": "services-per-organization", - "name": "Services per organization", - "description": "Limits services.", - "scope": "organization", - "value": 20, - "usage": 3, - "adjustable": true - } - ]))) - .mount(&s) - .await; - - let resp = c.organization_quotas_get_list("org-1").await.unwrap(); - let quotas = resp.result.unwrap(); - assert_eq!(quotas.len(), 1); - assert_eq!( - quotas[0].quota_code, - Some(OrganizationQuotaQuotacode::Services_per_organization) - ); - assert_eq!(quotas[0].scope, Some(OrganizationQuotaScope::Organization)); - assert_eq!(quotas[0].usage, Some(3)); -} - -#[tokio::test] -async fn get_quota() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/quotas/replicas-per-warehouse", - )) - .respond_with(ok_json(serde_json::json!({ - "quotaCode": "replicas-per-warehouse", - "name": "Replicas per warehouse", - "description": "Limits each warehouse individually.", - "scope": "warehouse", - "value": 20, - "adjustable": true - }))) - .mount(&s) - .await; - - let resp = c - .organization_quota_get("org-1", "replicas-per-warehouse") - .await - .unwrap(); - let quota = resp.result.unwrap(); - assert_eq!( - quota.quota_code, - Some(OrganizationQuotaQuotacode::Replicas_per_warehouse) - ); - assert_eq!(quota.scope, Some(OrganizationQuotaScope::Warehouse)); - assert_eq!(quota.usage, None); -} - -// =========================================================================== -// PostgreSQL Services -// =========================================================================== - -#[tokio::test] -async fn list_postgres_logs_with_filters() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/postgres/pg-1/logs")) - .and(query_param("from_date", "2026-08-01T00:00:00Z")) - .and(query_param("to_date", "2026-08-02T00:00:00Z")) - .and(query_param("body_contains", "checkpoint")) - .and(query_param("severity", "LOG")) - .and(query_param("sort_order", "asc")) - .and(query_param("limit", "100")) - .and(query_param("offset", "20")) - .respond_with(ok_json(serde_json::json!([{ - "timestamp": "2026-08-01T12:00:00Z", - "severity": "LOG", - "body": "checkpoint complete" - }]))) - .mount(&s) - .await; - - let logs = c - .postgres_logs_get_list( - "org-1", - "pg-1", - "2026-08-01T00:00:00Z", - "2026-08-02T00:00:00Z", - Some("checkpoint"), - Some("LOG"), - Some(&PostgresLogsGetListSortorder::Asc), - Some(100), - Some(20), - ) - .await - .unwrap() - .result - .unwrap(); - assert_eq!(logs.len(), 1); - assert_eq!(logs[0].body.as_deref(), Some("checkpoint complete")); -} - -#[tokio::test] -async fn list_slow_query_patterns_with_typed_sorting() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/postgres/pg-1/slowQueryPatterns", - )) - .and(query_param("from_date", "2026-08-01T00:00:00Z")) - .and(query_param("to_date", "2026-08-02T00:00:00Z")) - .and(query_param("db_name", "analytics")) - .and(query_param("db_user", "reporter")) - .and(query_param("db_operation", "SELECT")) - .and(query_param("app", "dashboard")) - .and(query_param("sort_by", "p95_duration")) - .and(query_param("sort_order", "asc")) - .and(query_param("limit", "100")) - .and(query_param("offset", "20")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - let patterns = c - .slow_query_patterns_get_list( - "org-1", - "pg-1", - "2026-08-01T00:00:00Z", - "2026-08-02T00:00:00Z", - Some("analytics"), - Some("reporter"), - Some("SELECT"), - Some("dashboard"), - Some(&SlowQueryPatternsGetListSortby::P95_duration), - Some(&SlowQueryPatternsGetListSortorder::Asc), - Some(100), - Some(20), - ) - .await - .unwrap() - .result - .unwrap(); - assert!(patterns.is_empty()); -} - -#[tokio::test] -async fn create_postgres_service() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/postgres")) - .and(body_partial_json(serde_json::json!({"name": "pg-svc", "provider": "aws", "region": "us-east-1", "size": "c6gd.large", "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"}}))) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "pg-svc", - "state": "creating", - "provider": "aws", - "region": "us-east-1", - "password": "generated-pw" - }))) - .mount(&s) - .await; - - let body = PostgresServicePostRequest { - name: "pg-svc".to_string(), - provider: PgProvider::Aws, - region: "us-east-1".to_string(), - size: PgSize::C6gd_large, - pg_bouncer_config: Some(PgBouncerConfig::from([ - ("default_pool_size".into(), "16".into()), - ("future_parameter".into(), "on".into()), - ])), - ..Default::default() - }; - let resp = c.postgres_service_create("org-1", &body).await.unwrap(); - let pg = resp.result.unwrap(); - assert_eq!(pg.name.as_deref(), Some("pg-svc")); - assert_eq!(pg.password.as_deref(), Some("generated-pw")); -} - -#[tokio::test] -async fn list_postgres_services() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/postgres")) - .respond_with(ok_json(serde_json::json!([ - { - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "pg-1", - "state": "running" - } - ]))) - .mount(&s) - .await; - - let resp = c.postgres_service_get_list("org-1").await.unwrap(); - let services = resp.result.unwrap(); - assert_eq!(services.len(), 1); - assert_eq!(services[0].name.as_deref(), Some("pg-1")); -} - -#[tokio::test] -async fn get_postgres_service() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/postgres/pg-1")) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "pg-1", - "state": "running", - "connectionString": "postgres://user@host/db" - }))) - .mount(&s) - .await; - - let resp = c.postgres_service_get("org-1", "pg-1").await.unwrap(); - let pg = resp.result.unwrap(); - assert_eq!(pg.name.as_deref(), Some("pg-1")); - assert_eq!( - pg.connection_string.as_deref(), - Some("postgres://user@host/db") - ); -} - -#[tokio::test] -async fn update_postgres_service() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/postgres/pg-1")) - .and(body_partial_json(serde_json::json!({"size": "c6gd.large"}))) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "pg-1" - }))) - .mount(&s) - .await; - - let body = PostgresServicePatchRequest { - size: Some(PgSize::C6gd_large), - ..Default::default() - }; - let resp = c - .postgres_service_patch("org-1", "pg-1", &body) - .await - .unwrap(); - let pg = resp.result.unwrap(); - assert_eq!(pg.name.as_deref(), Some("pg-1")); -} - -#[tokio::test] -async fn delete_postgres_service() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path("/v1/organizations/org-1/postgres/pg-1")) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c.postgres_service_delete("org-1", "pg-1").await.unwrap(); - assert_eq!(resp.status, Some(200)); -} - -#[tokio::test] -async fn update_postgres_service_state() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/postgres/pg-1/state")) - .and(body_partial_json(serde_json::json!({"command": "restart"}))) - .respond_with(ok_json(serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "pg-1", - "state": "restarting" - }))) - .mount(&s) - .await; - - let body = PostgresServiceSetState { - command: PostgresServiceSetStateCommand::Restart, - }; - let resp = c - .postgres_service_patch_state("org-1", "pg-1", &body) - .await - .unwrap(); - let pg = resp.result.unwrap(); - assert_eq!(pg.name.as_deref(), Some("pg-1")); -} - -#[tokio::test] -async fn set_postgres_password() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/postgres/pg-1/password")) - .and(body_partial_json( - serde_json::json!({"password": "new-pg-password"}), - )) - .respond_with(ok_json(serde_json::json!({ - "password": "new-pg-password" - }))) - .mount(&s) - .await; - - let body = PostgresServiceSetPassword { - password: "new-pg-password".to_string(), - }; - let resp = c - .postgres_service_set_password("org-1", "pg-1", &body) - .await - .unwrap(); - let result = resp.result.unwrap(); - assert_eq!(result.password.as_deref(), Some("new-pg-password")); -} - -#[tokio::test] -async fn get_postgres_certs() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/postgres/pg-1/caCertificates")) - .respond_with( - ResponseTemplate::new(200).set_body_string( - "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----\n", - ), - ) - .mount(&s) - .await; - - let resp = c.postgres_service_certs_get("org-1", "pg-1").await.unwrap(); - assert!(resp.contains("BEGIN CERTIFICATE")); -} - -#[tokio::test] -async fn get_postgres_config() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/postgres/pg-1/config")) - .respond_with(ok_json(serde_json::json!({ - "pgConfig": { - "max_connections": 100 - }, - "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"} - }))) - .mount(&s) - .await; - - let resp = c - .postgres_instance_config_get("org-1", "pg-1") - .await - .unwrap(); - let config = resp.result.unwrap(); - assert_eq!( - config.pg_bouncer_config.as_ref().unwrap()["default_pool_size"], - "16" - ); - assert_eq!( - config.pg_bouncer_config.as_ref().unwrap()["future_parameter"], - "on" - ); - assert_eq!( - config - .pg_config - .expect("pgConfig in response") - .max_connections, - Some(serde_json::json!(100)) - ); -} - -#[tokio::test] -async fn replace_postgres_config() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/postgres/pg-1/config")) - .and(body_partial_json( - serde_json::json!({"pgConfig": {"max_connections": 200}, "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"}}), - )) - .respond_with(ok_json(serde_json::json!({ - "message": "Configuration updated", - "pgConfig": { "max_connections": 200 }, - "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"} - }))) - .mount(&s) - .await; - - let body = PostgresInstanceConfig { - pg_config: PgConfig { - max_connections: Some(serde_json::json!(200)), - ..Default::default() - }, - pg_bouncer_config: PgBouncerConfig::from([ - ("default_pool_size".into(), "16".into()), - ("future_parameter".into(), "on".into()), - ]), - }; - let resp = c - .postgres_instance_config_post("org-1", "pg-1", &body) - .await - .unwrap(); - let result = resp.result.unwrap(); - assert_eq!( - result.pg_bouncer_config.as_ref().unwrap()["default_pool_size"], - "16" - ); - assert_eq!( - result.pg_bouncer_config.as_ref().unwrap()["future_parameter"], - "on" - ); - assert_eq!(result.message, Some("Configuration updated".to_string())); - assert_eq!( - result - .pg_config - .expect("pgConfig in response") - .max_connections, - Some(serde_json::json!(200)) - ); -} - -#[tokio::test] -async fn patch_postgres_config() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/postgres/pg-1/config")) - .and(body_partial_json( - serde_json::json!({"pgConfig": {"max_connections": 150}, "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"}}), - )) - .respond_with(ok_json(serde_json::json!({ - "message": "OK", - "pgConfig": { "max_connections": 150 }, - "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"} - }))) - .mount(&s) - .await; - - let body = PostgresInstanceConfig { - pg_config: PgConfig { - max_connections: Some(serde_json::json!(150)), - ..Default::default() - }, - pg_bouncer_config: PgBouncerConfig::from([ - ("default_pool_size".into(), "16".into()), - ("future_parameter".into(), "on".into()), - ]), - }; - let resp = c - .postgres_instance_config_patch("org-1", "pg-1", &body) - .await - .unwrap(); - let result = resp.result.unwrap(); - assert_eq!( - result.pg_bouncer_config.as_ref().unwrap()["default_pool_size"], - "16" - ); - assert_eq!( - result.pg_bouncer_config.as_ref().unwrap()["future_parameter"], - "on" - ); - assert_eq!( - result - .pg_config - .expect("pgConfig in response") - .max_connections, - Some(serde_json::json!(150)) - ); -} - -#[tokio::test] -async fn create_postgres_read_replica() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/postgres/pg-1/readReplica")) - .and(body_partial_json( - serde_json::json!({"name": "pg-1-replica", "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"}}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", - "name": "pg-1-replica", - "isPrimary": false - }))) - .mount(&s) - .await; - - let body = PostgresServiceReadReplicaRequest { - name: "pg-1-replica".to_string(), - pg_bouncer_config: Some(PgBouncerConfig::from([ - ("default_pool_size".into(), "16".into()), - ("future_parameter".into(), "on".into()), - ])), - ..Default::default() - }; - let resp = c - .postgres_instance_create_read_replica("org-1", "pg-1", &body) - .await - .unwrap(); - let pg = resp.result.unwrap(); - assert_eq!(pg.is_primary, Some(false)); -} - -#[tokio::test] -async fn restore_postgres_service() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path( - "/v1/organizations/org-1/postgres/pg-1/restoredService", - )) - .and(body_partial_json( - serde_json::json!({"name": "pg-1-restored", "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"}}), - )) - .respond_with(ok_json(serde_json::json!({ - "id": "cccccccc-dddd-eeee-ffff-000000000000", - "name": "pg-1-restored", - "state": "creating" - }))) - .mount(&s) - .await; - - let body = PostgresServiceRestoreRequest { - name: "pg-1-restored".to_string(), - pg_bouncer_config: Some(PgBouncerConfig::from([ - ("default_pool_size".into(), "16".into()), - ("future_parameter".into(), "on".into()), - ])), - restore_target: Utc::now(), - ..Default::default() - }; - let resp = c - .postgres_instance_restore("org-1", "pg-1", &body) - .await - .unwrap(); - let pg = resp.result.unwrap(); - assert_eq!(pg.name.as_deref(), Some("pg-1-restored")); -} - -// =========================================================================== -// Authentication -// =========================================================================== - -#[tokio::test] -async fn bearer_auth_sends_token() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .and(bearer_token("my-oauth-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": [ - { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "Bearer Org" - } - ] - }))) - .mount(&mock_server) - .await; - - let client = Client::with_bearer_token(mock_server.uri(), "my-oauth-token"); - let resp = client.organization_get_list(None, None).await.unwrap(); - let orgs = resp.result.unwrap(); - assert_eq!(orgs.len(), 1); - assert_eq!(orgs[0].name.as_deref(), Some("Bearer Org")); -} - -#[tokio::test] -async fn set_bearer_token_updates_token() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .and(bearer_token("refreshed-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "result": [] - }))) - .mount(&mock_server) - .await; - - let mut client = Client::with_bearer_token(mock_server.uri(), "old-token"); - client.set_bearer_token("refreshed-token").unwrap(); - let resp = client.organization_get_list(None, None).await.unwrap(); - assert_eq!(resp.result.unwrap().len(), 0); -} - -#[tokio::test] -async fn with_http_client_basic_auth() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .and(basic_auth("custom-key", "custom-secret")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&mock_server) - .await; - - let http = reqwest::Client::new(); - let client = Client::with_http_client(http, mock_server.uri(), "custom-key", "custom-secret"); - let resp = client.organization_get_list(None, None).await.unwrap(); - assert_eq!(resp.result.unwrap().len(), 0); -} - -#[tokio::test] -async fn with_http_client_bearer_auth() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .and(bearer_token("custom-bearer")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&mock_server) - .await; - - let http = reqwest::Client::new(); - let client = Client::with_http_client_bearer(http, mock_server.uri(), "custom-bearer"); - let resp = client.organization_get_list(None, None).await.unwrap(); - assert_eq!(resp.result.unwrap().len(), 0); -} - -#[test] -fn set_bearer_token_errors_on_basic_auth() { - let mut client = Client::new("key", "secret"); - let err = client.set_bearer_token("token").unwrap_err(); - assert!( - err.to_string().contains("auth mismatch"), - "unexpected error: {err}" - ); -} - -// =========================================================================== -// Error handling -// =========================================================================== - -#[tokio::test] -async fn api_error_response() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ - "status": 401, - "error": "Invalid credentials", - "requestId": "req-err-123" - }))) - .mount(&mock_server) - .await; - - let client = Client::with_base_url(mock_server.uri(), "bad-key", "bad-secret"); - let err = client.organization_get_list(None, None).await.unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 401); - assert_eq!(message, "Invalid credentials"); - } - other => panic!("Expected Api error, got: {:?}", other), - } -} - -#[tokio::test] -async fn api_error_403_forbidden() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/services")) - .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ - "status": 403, - "error": "Forbidden: insufficient permissions" - }))) - .mount(&s) - .await; - - let body = ServicePostRequest { - name: "test".to_string(), - ..Default::default() - }; - let err = c.instance_create("org-1", &body).await.unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 403); - assert!(message.contains("Forbidden")); - } - other => panic!("Expected Api error, got: {:?}", other), - } -} - -#[tokio::test] -async fn api_error_404_not_found() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/nonexistent")) - .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ - "status": 404, - "error": "Service not found" - }))) - .mount(&s) - .await; - - let err = c.instance_get("org-1", "nonexistent").await.unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 404); - assert_eq!(message, "Service not found"); - } - other => panic!("Expected Api error, got: {:?}", other), - } -} - -#[tokio::test] -async fn api_error_500_server_error() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ - "status": 500, - "error": "Internal server error" - }))) - .mount(&s) - .await; - - let err = c.organization_get_list(None, None).await.unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 500); - assert_eq!(message, "Internal server error"); - } - other => panic!("Expected Api error, got: {:?}", other), - } -} - -#[tokio::test] -async fn api_error_non_json_body() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway")) - .mount(&s) - .await; - - let err = c.organization_get_list(None, None).await.unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 502); - // Falls back to raw body text when JSON parsing fails - assert_eq!(message, "Bad Gateway"); - } - other => panic!("Expected Api error, got: {:?}", other), - } -} - -#[tokio::test] -async fn api_error_on_prometheus_endpoint() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/prometheus")) - .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ - "status": 403, - "error": "Metrics access denied" - }))) - .mount(&s) - .await; - - let err = c - .organization_prometheus_get("org-1", None) - .await - .unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 403); - assert_eq!(message, "Metrics access denied"); - } - other => panic!("Expected Api error, got: {:?}", other), - } -} - -#[tokio::test] -async fn api_error_on_postgres_certs_endpoint() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/postgres/pg-1/caCertificates")) - .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ - "error": "Postgres service not found" - }))) - .mount(&s) - .await; - - let err = c - .postgres_service_certs_get("org-1", "pg-1") - .await - .unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 404); - assert_eq!(message, "Postgres service not found"); - } - other => panic!("Expected Api error, got: {:?}", other), - } -} - -// =========================================================================== -// Malformed responses (Error::Json coverage) -// =========================================================================== - -#[tokio::test] -async fn malformed_json_success_response() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .respond_with(ResponseTemplate::new(200).set_body_string("this is not json")) - .mount(&s) - .await; - - let err = c.organization_get_list(None, None).await.unwrap_err(); - match err { - clickhouse_cloud_api::Error::Json(_) => {} // expected - other => panic!("Expected Json error, got: {:?}", other), - } -} - -#[tokio::test] -async fn truncated_json_success_response() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": 200, "result":"#)) - .mount(&s) - .await; - - let err = c.organization_get_list(None, None).await.unwrap_err(); - match err { - clickhouse_cloud_api::Error::Json(_) => {} // expected - other => panic!("Expected Json error, got: {:?}", other), - } -} - -// =========================================================================== -// Additional HTTP status codes -// =========================================================================== - -#[tokio::test] -async fn api_error_429_rate_limited() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .respond_with(ResponseTemplate::new(429).set_body_json(serde_json::json!({ - "status": 429, - "error": "Rate limit exceeded" - }))) - .mount(&s) - .await; - - let err = c.organization_get_list(None, None).await.unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 429); - assert_eq!(message, "Rate limit exceeded"); - } - other => panic!("Expected Api error, got: {:?}", other), - } -} - -#[tokio::test] -async fn api_error_422_validation() { - let (s, c) = setup().await; - - Mock::given(method("POST")) - .and(path("/v1/organizations/org-1/services")) - .respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({ - "status": 422, - "error": "Validation failed: name is required" - }))) - .mount(&s) - .await; - - let body = ServicePostRequest::default(); - let err = c.instance_create("org-1", &body).await.unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 422); - assert!(message.contains("Validation failed")); - } - other => panic!("Expected Api error, got: {:?}", other), - } -} - -// =========================================================================== -// Empty collection responses -// =========================================================================== - -#[tokio::test] -async fn list_services_returns_empty_vec() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - let resp = c.instance_get_list("org-1", &[]).await.unwrap(); - let services = resp.result.unwrap(); - assert!(services.is_empty()); -} - -#[tokio::test] -async fn list_organizations_returns_empty_vec() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - let resp = c.organization_get_list(None, None).await.unwrap(); - let orgs = resp.result.unwrap(); - assert!(orgs.is_empty()); -} - -// =========================================================================== -// Query parameter coverage -// =========================================================================== - -#[tokio::test] -async fn list_services_with_filters() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services")) - .and(query_param("filter", "state=running")) - .respond_with(ok_json(serde_json::json!([ - {"id": "11111111-2222-3333-4444-555555555555", "name": "svc-1", "state": "running"} - ]))) - .mount(&s) - .await; - - let resp = c - .instance_get_list("org-1", &["state=running"]) - .await - .unwrap(); - let services = resp.result.unwrap(); - assert_eq!(services.len(), 1); -} - -#[tokio::test] -async fn list_services_with_multiple_filters() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services")) - .and(query_param("filter", "state=running")) - .and(query_param("filter", "tier=production")) - .respond_with(ok_json(serde_json::json!([ - {"id": "11111111-2222-3333-4444-555555555555", "name": "svc-1", "state": "running"} - ]))) - .mount(&s) - .await; - - let resp = c - .instance_get_list("org-1", &["state=running", "tier=production"]) - .await - .unwrap(); - let services = resp.result.unwrap(); - assert_eq!(services.len(), 1); -} - -#[tokio::test] -async fn usage_cost_with_filters() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/usageCost")) - .and(query_param("from_date", "2024-01-01")) - .and(query_param("to_date", "2024-01-31")) - .and(query_param("filter", "service_id=svc-1")) - .respond_with(ok_json(serde_json::json!({ - "costs": [], - "grandTotalCHC": 10.0 - }))) - .mount(&s) - .await; - - let resp = c - .usage_cost_get("org-1", "2024-01-01", "2024-01-31", &["service_id=svc-1"]) - .await - .unwrap(); - let cost = resp.result.unwrap(); - assert_eq!(cost.grand_total_chc, Some(10.0)); -} - -#[tokio::test] -async fn activity_list_with_only_from_date() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/activities")) - .and(query_param("from_date", "2024-06-01")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&s) - .await; - - let resp = c - .activity_get_list("org-1", Some("2024-06-01"), None) - .await - .unwrap(); - let activities = resp.result.unwrap(); - assert!(activities.is_empty()); -} - -#[tokio::test] -async fn organization_prometheus_with_filtered_metrics() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/prometheus")) - .and(query_param("filtered_metrics", "cpu,memory")) - .respond_with(ResponseTemplate::new(200).set_body_string("cpu 42\nmemory 1024\n")) - .mount(&s) - .await; - - let resp = c - .organization_prometheus_get("org-1", Some("cpu,memory")) - .await - .unwrap(); - assert!(resp.contains("cpu 42")); -} - -#[tokio::test] -async fn organization_prometheus_without_filter() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/prometheus")) - .respond_with(ResponseTemplate::new(200).set_body_string("# HELP metric\nmetric 1\n")) - .mount(&s) - .await; - - let resp = c.organization_prometheus_get("org-1", None).await.unwrap(); - assert!(resp.contains("metric")); -} - -#[tokio::test] -async fn postgres_instance_prometheus_get_returns_metrics() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/postgres/pg-1/prometheus")) - .respond_with(ResponseTemplate::new(200).set_body_string("# HELP pg_metric\npg_metric 7\n")) - .mount(&s) - .await; - - let resp = c - .postgres_instance_prometheus_get("org-1", "pg-1") - .await - .unwrap(); - assert!(resp.contains("pg_metric")); -} - -#[tokio::test] -async fn postgres_org_prometheus_get_returns_metrics() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/postgres/prometheus")) - .respond_with( - ResponseTemplate::new(200).set_body_string("# HELP pg_org_metric\npg_org_metric 3\n"), - ) - .mount(&s) - .await; - - let resp = c.postgres_org_prometheus_get("org-1").await.unwrap(); - assert!(resp.contains("pg_org_metric")); -} - -#[tokio::test] -async fn scaling_schedule_delete_succeeds() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path( - "/v1/organizations/org-1/services/svc-1/scalingSchedule", - )) - .respond_with(ok_json(serde_json::json!({ - "status": 200, - "requestId": "00000000-0000-0000-0000-000000000000" - }))) - .mount(&s) - .await; - - let resp = c.scaling_schedule_delete("org-1", "svc-1").await.unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// Service: Upgrade Window -// =========================================================================== - -#[tokio::test] -async fn upgrade_window_get_returns_window() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/upgradeWindow")) - .respond_with(ok_json(serde_json::json!({ - "weekday": 2, - "startHourUtc": 6, - "duration": 6 - }))) - .mount(&s) - .await; - - let resp = c.upgrade_window_get("org-1", "svc-1").await.unwrap(); - let window = resp.result.unwrap(); - assert_eq!(window.weekday, Some(2)); - assert_eq!( - window.start_hour_utc, - Some(UpgradeWindowStartHourUtc::Hour6) - ); - assert_eq!(window.duration, Some(UpgradeWindowDuration::SixHours)); -} - -#[tokio::test] -async fn upgrade_window_update_sends_body() { - let (s, c) = setup().await; - - Mock::given(method("PUT")) - .and(path("/v1/organizations/org-1/services/svc-1/upgradeWindow")) - .and(body_partial_json(serde_json::json!({ - "weekday": 2, - "startHourUtc": 6 - }))) - .respond_with(ok_json(serde_json::json!({ - "weekday": 2, - "startHourUtc": 6, - "duration": 6 - }))) - .mount(&s) - .await; - - let body = UpgradeWindowPutRequest { - weekday: 2, - start_hour_utc: UpgradeWindowStartHourUtc::Hour6, - }; - let resp = c - .upgrade_window_update("org-1", "svc-1", &body) - .await - .unwrap(); - let window = resp.result.unwrap(); - assert_eq!(window.weekday, Some(2)); - assert_eq!( - window.start_hour_utc, - Some(UpgradeWindowStartHourUtc::Hour6) - ); -} - -#[tokio::test] -async fn upgrade_window_delete_succeeds() { - let (s, c) = setup().await; - - Mock::given(method("DELETE")) - .and(path("/v1/organizations/org-1/services/svc-1/upgradeWindow")) - .respond_with(ok_empty()) - .mount(&s) - .await; - - let resp = c.upgrade_window_delete("org-1", "svc-1").await.unwrap(); - assert_eq!(resp.status, Some(200)); -} - -// =========================================================================== -// Base URL handling -// =========================================================================== - -#[tokio::test] -async fn base_url_trailing_slash_stripped() { - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations")) - .respond_with(ok_json(serde_json::json!([]))) - .mount(&mock_server) - .await; - - let url_with_slash = format!("{}/", mock_server.uri()); - let client = Client::with_base_url(url_with_slash, "key", "secret"); - let resp = client.organization_get_list(None, None).await.unwrap(); - assert_eq!(resp.result.unwrap().len(), 0); -} - -#[tokio::test] -async fn default_base_url_is_production() { - // Client::new() uses https://api.clickhouse.cloud -- we can't hit it, - // but we can verify the client is constructable without panicking. - let _client = Client::new("key", "secret"); -} - -#[tokio::test] -async fn credit_balances_get_includes_trial_and_prepaid_balances() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org/creditBalances")) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!({ - "totalRemainingCredits": 12.5, - "balances": [{"type": "trial", "remainingCredits": 2.5}, {"type": "prepaid", "remainingCredits": 10.0}] - }))) - .expect(1).mount(&server).await; - let result = client - .credit_balances_get("org") - .await - .unwrap() - .result - .unwrap(); - assert_eq!(result.total_remaining_credits, Some(12.5)); - let balances = result.balances.unwrap(); - assert_eq!(balances[0].r#type, Some(CreditBalanceType::Trial)); - assert_eq!(balances[1].r#type, Some(CreditBalanceType::Prepaid)); -} - -#[tokio::test] -async fn service_profiles_list_encodes_optional_region_and_byoc() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org/serviceProfiles")) - .and(basic_auth("key", "secret")) - .respond_with(ok_json( - serde_json::json!([{"profile": "v1-standard-byoc-4", "cpuCores": 4, "memoryGi": 16}]), - )) - .expect(4) - .mount(&server) - .await; - let region = "region +/=&?雪"; - let byoc = "byoc +/=&?雪"; - for (region_id, byoc_id) in [ - (None, None), - (Some(region), None), - (None, Some(byoc)), - (Some(region), Some(byoc)), - ] { - let result = client - .service_profiles_list("org", region_id, byoc_id) - .await - .unwrap() - .result - .unwrap(); - assert_eq!(result[0].profile.as_deref(), Some("v1-standard-byoc-4")); - assert_eq!(result[0].cpu_cores, Some(4.0)); - assert_eq!(result[0].memory_gi, Some(16.0)); - } - let requests = server.received_requests().await.unwrap(); - let queries: Vec> = requests - .iter() - .map(|request| request.url.query_pairs().into_owned().collect()) - .collect(); - assert_eq!( - queries, - vec![ - vec![], - vec![("region_id".into(), region.into())], - vec![("byoc_id".into(), byoc.into())], - vec![ - ("region_id".into(), region.into()), - ("byoc_id".into(), byoc.into()) - ], - ] - ); -} - -#[tokio::test] -async fn clickpipes_context_returns_workload_identity_and_tolerates_omissions() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org/services/service/clickpipes/context")) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!({"gcpWorkloadIdentity": { - "supported": true, "ready": null, "principal": "clickpipes@example.iam.gserviceaccount.com" - }}))) - .expect(1).mount(&server).await; - let result = client - .click_pipes_service_context_get("org", "service") - .await - .unwrap() - .result - .unwrap(); - let identity = result.gcp_workload_identity.unwrap(); - assert_eq!(identity.supported, Some(true)); - assert_eq!(identity.ready, None); - assert_eq!( - identity.principal.as_deref(), - Some("clickpipes@example.iam.gserviceaccount.com") - ); -} - -#[tokio::test] -async fn new_discovery_operations_preserve_api_errors() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .respond_with( - ResponseTemplate::new(403).set_body_json(serde_json::json!({"error": "forbidden"})), - ) - .expect(3) - .mount(&server) - .await; - let errors = [ - client.credit_balances_get("org").await.unwrap_err(), - client - .service_profiles_list("org", Some("region"), None) - .await - .unwrap_err(), - client - .click_pipes_service_context_get("org", "service") - .await - .unwrap_err(), - ]; - for error in errors { - assert!( - matches!(error, clickhouse_cloud_api::Error::Api { status: 403, message } if message == "forbidden") - ); - } -} - -#[tokio::test] -async fn update_api_key_sends_omitted_timestamp_and_null_expiry() { - let timestamp = chrono::DateTime::parse_from_rfc3339("2030-01-02T03:04:05Z") - .unwrap() - .with_timezone(&Utc); - for (expire_at, wire) in [ - (None, serde_json::json!({"name": "retained"})), - ( - Some(Some(timestamp)), - serde_json::json!({"name": "retained", "expireAt": "2030-01-02T03:04:05Z"}), - ), - ( - Some(None), - serde_json::json!({"name": "retained", "expireAt": null}), - ), - ] { - let (server, client) = setup().await; - Mock::given(method("PATCH")) - .and(path("/v1/organizations/org-1/keys/key-1")) - .and(body_json(wire)) - .respond_with(ok_json(serde_json::json!({"name": "retained"}))) - .expect(1) - .mount(&server) - .await; - let request = ApiKeyPatchRequest { - name: Some("retained".into()), - expire_at, - ..Default::default() - }; - let response = client - .openapi_key_update("org-1", "key-1", &request) - .await - .unwrap(); - assert_eq!(response.result.unwrap().name.as_deref(), Some("retained")); - } -} - -#[tokio::test] -async fn service_clickhouse_settings_update_sends_and_receives_objects() { - let (server, client) = setup().await; - let settings = serde_json::json!({"compatibility": "26.2", "max_query_size": 262144}); - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/clickhouseSettings", - )) - .and(basic_auth("key", "secret")) - .and(body_json(serde_json::json!({"settings": settings}))) - .respond_with(ok_json( - serde_json::json!({"settings": settings, "warnings": []}), - )) - .expect(3) - .mount(&server) - .await; - let body: ServiceClickhouseSettingsPatchRequest = - serde_json::from_value(serde_json::json!({"settings": settings})).unwrap(); - let response = client - .service_clickhouse_settings_update("org-1", "svc-1", &body) - .await - .unwrap(); - assert_eq!( - serde_json::to_value(response.result.unwrap().settings.unwrap()).unwrap(), - settings - ); - let legacy = ServiceClickhouseSettingsPatchRequest { - settings: Some(settings.to_string()), - }; - client - .service_clickhouse_settings_update("org-1", "svc-1", &legacy) - .await - .unwrap(); - let typed = ServiceClickhouseSettingsPatchRequest { - settings: Some(serde_json::from_value::(settings).unwrap()), - }; - client - .service_clickhouse_settings_update("org-1", "svc-1", &typed) - .await - .unwrap(); -} - -#[tokio::test] -async fn service_clickhouse_settings_update_rejects_invalid_legacy_input_before_http() { - let (server, client) = setup().await; - for invalid in ["{broken", "{}", "[]", "null", "42", r#""string""#] { - let body = ServiceClickhouseSettingsPatchRequest { - settings: Some(invalid.to_string()), - }; - assert!( - client - .service_clickhouse_settings_update("org-1", "svc-1", &body) - .await - .is_err() - ); - } - assert!(server.received_requests().await.unwrap().is_empty()); -} - -#[tokio::test] -async fn service_clickhouse_setting_reads_preserve_numeric_and_string_values() { - let (server, client) = setup().await; - let collection = "/v1/organizations/org-1/services/svc-1/clickhouseSettings"; - let settings = serde_json::json!([ - {"name": "max_query_size", "value": 262146}, - {"name": "compatibility", "value": "26.2"}, - {"name": "future_bool", "value": false} - ]); - for setting in settings.as_array().unwrap() { - let name = setting["name"].as_str().unwrap(); - Mock::given(method("GET")) - .and(path(format!("{collection}/{name}"))) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(setting.clone())) - .expect(1) - .mount(&server) - .await; - let response = client - .service_clickhouse_setting_get("org-1", "svc-1", name) - .await - .unwrap(); - assert_eq!( - serde_json::to_value(response.result.unwrap()).unwrap(), - *setting - ); - } - Mock::given(method("GET")) - .and(path(collection)) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!({"settings": settings}))) - .expect(1) - .mount(&server) - .await; - let response = client - .service_clickhouse_settings_list_get("org-1", "svc-1") - .await - .unwrap(); - assert_eq!( - serde_json::to_value(response.result.unwrap()).unwrap(), - serde_json::json!({"settings": settings}) - ); -} - -#[tokio::test] -async fn query_api_endpoint_create_and_update_send_complete_requests() { - let (server, client) = setup().await; - let collection = "/v1/organizations/org-1/services/svc-1/query-api-endpoints"; - let minimal = serde_json::json!({ - "name": "daily total", "sql": "SELECT count() FROM events", "database": "default", - "apiKeyIds": ["00000000-0000-4000-8000-000000000001"], "roles": ["reader"] - }); - let maximal = serde_json::json!({ - "name": "filtered total", "sql": "SELECT count() FROM events WHERE kind = {kind:String}", - "database": "analytics", "apiKeyIds": ["00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002"], "roles": ["reader"], - "parameters": {"kind": "page view"}, "allowedOrigins": ["https://example.com"] - }); - for body in [minimal, maximal] { - let request: PublicQueryApiEndpointRequest = serde_json::from_value(body.clone()).unwrap(); - let mut returned = body.clone(); - returned["id"] = serde_json::json!("00000000-0000-4000-8000-000000000003"); - returned["url"] = serde_json::json!("https://queries.clickhouse.cloud/run/endpoint-1"); - returned["ownerType"] = serde_json::json!("queryApiEndpoint"); - Mock::given(method("POST")) - .and(path(collection)) - .and(basic_auth("key", "secret")) - .and(body_json(body.clone())) - .respond_with(created_json(returned.clone())) - .expect(1) - .mount(&server) - .await; - let response = client - .query_api_endpoint_create("org-1", "svc-1", &request) - .await - .unwrap(); - assert_eq!(response.status, Some(201)); - assert_eq!( - serde_json::to_value(response.result.unwrap()).unwrap(), - returned - ); - - Mock::given(method("PUT")) - .and(path(format!("{collection}/endpoint-1"))) - .and(basic_auth("key", "secret")) - .and(body_json(body)) - .respond_with(ok_json(returned.clone())) - .expect(1) - .mount(&server) - .await; - let response = client - .query_api_endpoint_update("org-1", "svc-1", "endpoint-1", &request) - .await - .unwrap(); - assert_eq!( - serde_json::to_value(response.result.unwrap()).unwrap(), - returned - ); - } -} - -#[tokio::test] -async fn query_api_endpoint_get_and_delete_use_endpoint_path() { - let (server, client) = setup().await; - let endpoint_path = "/v1/organizations/org-1/services/svc-1/query-api-endpoints/endpoint-1"; - Mock::given(method("GET")) - .and(path(endpoint_path)) - .and(basic_auth("key", "secret")) - .respond_with(ok_json( - serde_json::json!({"id": "00000000-0000-4000-8000-000000000003", "name": "example"}), - )) - .expect(1) - .mount(&server) - .await; - let response = client - .query_api_endpoint_get("org-1", "svc-1", "endpoint-1") - .await - .unwrap(); - assert_eq!( - response.result.unwrap().id, - Some(uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000003").unwrap()) - ); - Mock::given(method("DELETE")) - .and(path(endpoint_path)) - .and(basic_auth("key", "secret")) - .respond_with(ok_empty()) - .expect(1) - .mount(&server) - .await; - let response = client - .query_api_endpoint_delete("org-1", "svc-1", "endpoint-1") - .await - .unwrap(); - assert_eq!(response.status, Some(200)); - assert_eq!(response.request_id.as_deref(), Some("req-test")); - assert!(response.result.is_none()); - for request in server.received_requests().await.unwrap() { - assert!(request.body.is_empty()); - assert!(request.url.query().is_none()); - } -} - -#[tokio::test] -async fn query_api_endpoint_list_encodes_cursor_and_omits_absent_parameters() { - for (cursor, limit) in [ - (None, None), - (Some("next+/=&? page"), None), - (None, Some(25)), - (Some("next+/=&? page"), Some(25)), - ] { - let (server, client) = setup().await; - let result = serde_json::json!([ - {"id": "00000000-0000-4000-8000-000000000003", "name": "example", "ownerType": "user"} - ]); - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/query-api-endpoints", - )) - .and(basic_auth("key", "secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "result": result, "limit": 25, "totalCount": 42, - "nextCursor": "another-page", "status": 200 - }))) - .expect(1) - .mount(&server) - .await; - let response = client - .query_api_endpoint_list("org-1", "svc-1", cursor, limit) - .await - .unwrap(); - assert_eq!(response.limit, Some(25)); - assert_eq!(response.total_count, Some(42)); - assert_eq!(response.next_cursor.as_deref(), Some("another-page")); - assert_eq!( - serde_json::to_value(response.result.unwrap()).unwrap(), - result - ); - let requests = server.received_requests().await.unwrap(); - let query: std::collections::HashMap<_, _> = - requests[0].url.query_pairs().into_owned().collect(); - let mut expected = std::collections::HashMap::new(); - if let Some(cursor) = cursor { - expected.insert("cursor".to_owned(), cursor.to_owned()); - } - if let Some(limit) = limit { - expected.insert("limit".to_owned(), limit.to_string()); - } - assert_eq!(query, expected); - if cursor.is_none() && limit.is_none() { - assert!(requests[0].url.query().is_none()); - } - assert!(requests[0].body.is_empty()); - } -} - -#[tokio::test] -async fn query_api_endpoint_reads_use_bearer_auth() { - let server = MockServer::start().await; - let client = Client::with_bearer_token(server.uri(), "token"); - for (endpoint_path, result) in [ - ( - "/v1/organizations/org/services/svc/query-api-endpoints", - serde_json::json!([]), - ), - ( - "/v1/organizations/org/services/svc/query-api-endpoints/endpoint", - serde_json::json!({}), - ), - ] { - Mock::given(method("GET")) - .and(path(endpoint_path)) - .and(bearer_token("token")) - .respond_with(ok_json(result)) - .expect(1) - .mount(&server) - .await; - } - client - .query_api_endpoint_list("org", "svc", None, None) - .await - .unwrap(); - client - .query_api_endpoint_get("org", "svc", "endpoint") - .await - .unwrap(); -} - -#[tokio::test] -async fn query_api_endpoint_methods_propagate_api_errors() { - for (status, body, expected_message) in [ - ( - 403, - serde_json::json!({"status": 403, "error": "forbidden"}).to_string(), - "forbidden", - ), - ( - 409, - serde_json::json!({"status": 409, "error": "user-owned endpoint"}).to_string(), - "user-owned endpoint", - ), - (500, "upstream failed".to_owned(), "upstream failed"), - ] { - let (server, client) = setup().await; - Mock::given(basic_auth("key", "secret")) - .respond_with(ResponseTemplate::new(status).set_body_string(body)) - .expect(5) - .mount(&server) - .await; - let request: PublicQueryApiEndpointRequest = serde_json::from_value(serde_json::json!({ - "name": "example", "sql": "SELECT 1", "database": "default", - "apiKeyIds": ["00000000-0000-4000-8000-000000000001"], "roles": ["reader"] - })) - .unwrap(); - let errors = [ - client - .query_api_endpoint_create("org", "svc", &request) - .await - .unwrap_err(), - client - .query_api_endpoint_get("org", "svc", "endpoint") - .await - .unwrap_err(), - client - .query_api_endpoint_list("org", "svc", None, None) - .await - .unwrap_err(), - client - .query_api_endpoint_update("org", "svc", "endpoint", &request) - .await - .unwrap_err(), - client - .query_api_endpoint_delete("org", "svc", "endpoint") - .await - .unwrap_err(), - ]; - for error in errors { - assert!( - matches!(error, clickhouse_cloud_api::Error::Api { status: actual_status, message } if actual_status == status && message == expected_message) - ); - } - } -} - -#[tokio::test] -async fn saved_query_create_and_update_send_complete_requests() { - let (server, client) = setup().await; - let collection = "/v1/organizations/org-1/services/svc-1/saved-queries"; - let minimal = serde_json::json!({ - "name": "daily total", "sql": "SELECT count() FROM events", "database": "default" - }); - let maximal = serde_json::json!({ - "name": "filtered total", "sql": "SELECT count() FROM events WHERE kind = {kind:String}", - "database": "analytics", "parameters": {"kind": "page view"} - }); - for body in [minimal, maximal] { - let request: PublicSavedQueryRequest = serde_json::from_value(body.clone()).unwrap(); - let mut returned = body.clone(); - returned["id"] = serde_json::json!("00000000-0000-4000-8000-000000000003"); - if returned.get("parameters").is_none() { - returned["parameters"] = serde_json::json!({}); - } - Mock::given(method("POST")) - .and(path(collection)) - .and(basic_auth("key", "secret")) - .and(body_json(body.clone())) - .respond_with(created_json(returned.clone())) - .expect(1) - .mount(&server) - .await; - let response = client - .saved_query_create("org-1", "svc-1", &request) - .await - .unwrap(); - assert_eq!(response.status, Some(201)); - assert_eq!( - serde_json::to_value(response.result.unwrap()).unwrap(), - returned - ); - - Mock::given(method("PUT")) - .and(path(format!("{collection}/query-1"))) - .and(basic_auth("key", "secret")) - .and(body_json(body)) - .respond_with(ok_json(returned.clone())) - .expect(1) - .mount(&server) - .await; - let response = client - .saved_query_update("org-1", "svc-1", "query-1", &request) - .await - .unwrap(); - assert_eq!( - serde_json::to_value(response.result.unwrap()).unwrap(), - returned - ); - } - for request in server.received_requests().await.unwrap() { - assert!(request.url.query().is_none()); - } -} - -#[tokio::test] -async fn saved_query_get_and_delete_use_query_path() { - let (server, client) = setup().await; - let query_path = "/v1/organizations/org-1/services/svc-1/saved-queries/query-1"; - Mock::given(method("GET")) - .and(path(query_path)) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!({ - "id": "00000000-0000-4000-8000-000000000003", - "name": "example", - "sql": "SELECT {n:UInt8}", - "database": "default", - "parameters": {"n": "1"} - }))) - .expect(1) - .mount(&server) - .await; - let query = client - .saved_query_get("org-1", "svc-1", "query-1") - .await - .unwrap() - .result - .unwrap(); - assert_eq!( - query.id, - Some(uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000003").unwrap()) - ); - assert_eq!(query.name.as_deref(), Some("example")); - assert_eq!(query.sql.as_deref(), Some("SELECT {n:UInt8}")); - assert_eq!(query.database.as_deref(), Some("default")); - assert_eq!( - query.parameters, - Some(std::collections::BTreeMap::from([( - "n".to_string(), - "1".to_string() - )])) - ); - Mock::given(method("DELETE")) - .and(path(query_path)) - .and(basic_auth("key", "secret")) - .respond_with(ok_empty()) - .expect(1) - .mount(&server) - .await; - let response = client - .saved_query_delete("org-1", "svc-1", "query-1") - .await - .unwrap(); - assert_eq!(response.status, Some(200)); - assert_eq!(response.request_id.as_deref(), Some("req-test")); - assert!(response.result.is_none()); - for request in server.received_requests().await.unwrap() { - assert!(request.body.is_empty()); - assert!(request.url.query().is_none()); - } -} - -#[tokio::test] -async fn saved_query_list_encodes_cursor_and_reads_envelope_pagination() { - for (cursor, limit) in [ - (None, None), - (Some("next+/=&? page"), None), - (None, Some(25)), - (Some("next+/=&? page"), Some(25)), - ] { - let (server, client) = setup().await; - let items = serde_json::json!([ - {"id": "00000000-0000-4000-8000-000000000003", "name": "example", "database": "default"} - ]); - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/services/svc-1/saved-queries")) - .and(basic_auth("key", "secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "requestId": "req-test", - "result": items, - "limit": 25, - "totalCount": 2, - "nextCursor": "another-page" - }))) - .expect(1) - .mount(&server) - .await; - let response = client - .saved_query_list("org-1", "svc-1", cursor, limit) - .await - .unwrap(); - assert_eq!(response.limit, Some(25)); - assert_eq!(response.total_count, Some(2)); - assert_eq!(response.next_cursor.as_deref(), Some("another-page")); - assert_eq!( - serde_json::to_value(response.result.unwrap()).unwrap(), - items - ); - let requests = server.received_requests().await.unwrap(); - let query: std::collections::HashMap<_, _> = - requests[0].url.query_pairs().into_owned().collect(); - let mut expected = std::collections::HashMap::new(); - if let Some(cursor) = cursor { - expected.insert("cursor".to_owned(), cursor.to_owned()); - } - if let Some(limit) = limit { - expected.insert("limit".to_owned(), limit.to_string()); - } - assert_eq!(query, expected); - if cursor.is_none() && limit.is_none() { - assert!(requests[0].url.query().is_none()); - } - assert!(requests[0].body.is_empty()); - } -} - -#[tokio::test] -async fn saved_query_list_last_page_has_null_next_cursor() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org/services/svc/saved-queries")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "requestId": "req-test", - "result": [], - "limit": 100, - "totalCount": 0, - "nextCursor": null - }))) - .expect(1) - .mount(&server) - .await; - let response = client - .saved_query_list("org", "svc", None, None) - .await - .unwrap(); - assert_eq!(response.result, Some(vec![])); - assert_eq!(response.next_cursor, None); - assert_eq!(response.total_count, Some(0)); -} - -#[tokio::test] -async fn saved_query_reads_use_bearer_auth() { - let server = MockServer::start().await; - let client = Client::with_bearer_token(server.uri(), "token"); - for (query_path, result) in [ - ( - "/v1/organizations/org/services/svc/saved-queries", - serde_json::json!([]), - ), - ( - "/v1/organizations/org/services/svc/saved-queries/query", - serde_json::json!({}), - ), - ] { - Mock::given(method("GET")) - .and(path(query_path)) - .and(bearer_token("token")) - .respond_with(ok_json(result)) - .expect(1) - .mount(&server) - .await; - } - client - .saved_query_list("org", "svc", None, None) - .await - .unwrap(); - client.saved_query_get("org", "svc", "query").await.unwrap(); -} - -#[tokio::test] -async fn saved_query_methods_propagate_api_errors() { - for (status, body, expected_message) in [ - ( - 404, - serde_json::json!({"status": 404, "error": "Saved query not found"}).to_string(), - "Saved query not found", - ), - ( - 409, - serde_json::json!({"status": 409, "error": "name already exists"}).to_string(), - "name already exists", - ), - (500, "upstream failed".to_owned(), "upstream failed"), - ] { - let (server, client) = setup().await; - Mock::given(basic_auth("key", "secret")) - .respond_with(ResponseTemplate::new(status).set_body_string(body)) - .expect(5) - .mount(&server) - .await; - let request = PublicSavedQueryRequest { - name: "example".into(), - sql: "SELECT 1".into(), - database: "default".into(), - parameters: None, - }; - let errors = [ - client - .saved_query_create("org", "svc", &request) - .await - .unwrap_err(), - client - .saved_query_get("org", "svc", "query") - .await - .unwrap_err(), - client - .saved_query_list("org", "svc", None, None) - .await - .unwrap_err(), - client - .saved_query_update("org", "svc", "query", &request) - .await - .unwrap_err(), - client - .saved_query_delete("org", "svc", "query") - .await - .unwrap_err(), - ]; - for error in errors { - assert!( - matches!(error, clickhouse_cloud_api::Error::Api { status: actual_status, message } if actual_status == status && message == expected_message) - ); - } - } -} - -#[tokio::test] -async fn clickstack_list_pagination_encodes_only_supplied_parameters() { - for resource in ["alerts", "saved-searches", "webhooks"] { - for (limit, offset) in [ - (None, None), - (Some(25), None), - (None, Some(50)), - (Some(25), Some(50)), - ] { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path(format!( - "/v1/organizations/org-1/services/svc-1/clickstack/{resource}" - ))) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!([{}]))) - .expect(1) - .mount(&server) - .await; - let count = match resource { - "alerts" => client - .click_stack_list_alerts("org-1", "svc-1", limit, offset) - .await - .unwrap() - .result - .unwrap() - .len(), - "saved-searches" => client - .click_stack_list_saved_searches("org-1", "svc-1", limit, offset) - .await - .unwrap() - .result - .unwrap() - .len(), - "webhooks" => client - .click_stack_list_webhooks("org-1", "svc-1", limit, offset) - .await - .unwrap() - .result - .unwrap() - .len(), - _ => unreachable!(), - }; - assert_eq!(count, 1); - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - let actual: std::collections::BTreeMap<_, _> = requests[0] - .url - .query_pairs() - .map(|(key, value)| (key.into_owned(), value.into_owned())) - .collect(); - let expected: std::collections::BTreeMap<_, _> = [("limit", limit), ("offset", offset)] - .into_iter() - .filter_map(|(key, value)| value.map(|value| (key.to_owned(), value.to_string()))) - .collect(); - assert_eq!(actual, expected, "{resource}"); - } - } -} - -// =========================================================================== -// Postgres service backups -// =========================================================================== - -#[tokio::test] -async fn postgres_service_backup_get_list_encodes_cursor_and_reads_envelope_pagination() { - for (cursor, limit) in [ - (None, None), - (Some("next+/=&? page"), None), - (None, Some(25)), - (Some("next+/=&? page"), Some(25)), - ] { - let (server, client) = setup().await; - let backups = serde_json::json!([ - { - "key": "basebackups_005/000000010000000000000002_backup_stop_sentinel.json", - "lastModified": "2026-03-31T18:17:37Z" - } - ]); - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/postgres/pg-1/backups")) - .and(basic_auth("key", "secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "requestId": "req-test", - "result": backups, - "limit": 25, - "totalCount": 2, - "nextCursor": "another-page" - }))) - .expect(1) - .mount(&server) - .await; - let response = client - .postgres_service_backup_get_list("org-1", "pg-1", cursor, limit) - .await - .unwrap(); - assert_eq!(response.limit, Some(25)); - assert_eq!(response.total_count, Some(2)); - assert_eq!(response.next_cursor.as_deref(), Some("another-page")); - let result = response.result.unwrap(); - assert_eq!( - result[0].key.as_deref(), - Some("basebackups_005/000000010000000000000002_backup_stop_sentinel.json") - ); - assert_eq!( - result[0].last_modified, - Some( - chrono::DateTime::parse_from_rfc3339("2026-03-31T18:17:37Z") - .unwrap() - .with_timezone(&Utc) - ) - ); - let requests = server.received_requests().await.unwrap(); - let query: std::collections::HashMap<_, _> = - requests[0].url.query_pairs().into_owned().collect(); - let mut expected = std::collections::HashMap::new(); - if let Some(cursor) = cursor { - expected.insert("cursor".to_owned(), cursor.to_owned()); - } - if let Some(limit) = limit { - expected.insert("limit".to_owned(), limit.to_string()); - } - assert_eq!(query, expected); - if cursor.is_none() && limit.is_none() { - assert!(requests[0].url.query().is_none()); - } - assert!(requests[0].body.is_empty()); - } -} - -#[tokio::test] -async fn postgres_service_backup_get_list_last_page_has_null_next_cursor() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org/postgres/pg/backups")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, - "requestId": "req-test", - "result": [], - "limit": 100, - "totalCount": 0, - "nextCursor": null - }))) - .expect(1) - .mount(&server) - .await; - let response = client - .postgres_service_backup_get_list("org", "pg", None, None) - .await - .unwrap(); - assert_eq!(response.result, Some(vec![])); - assert_eq!(response.next_cursor, None); - assert_eq!(response.total_count, Some(0)); -} - -#[tokio::test] -async fn postgres_service_backup_get_list_propagates_api_errors() { - for (status, body, expected_message) in [ - ( - 404, - serde_json::json!({"status": 404, "error": "Postgres service not found"}).to_string(), - "Postgres service not found", - ), - (500, "upstream failed".to_owned(), "upstream failed"), - ] { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org/postgres/pg/backups")) - .respond_with(ResponseTemplate::new(status).set_body_string(body)) - .expect(1) - .mount(&server) - .await; - let error = client - .postgres_service_backup_get_list("org", "pg", None, None) - .await - .unwrap_err(); - assert!( - matches!(error, clickhouse_cloud_api::Error::Api { status: actual_status, ref message } if actual_status == status && message == expected_message), - "unexpected error: {error:?}" - ); - } -} - -// =========================================================================== -// Whoami -// =========================================================================== - -#[tokio::test] -async fn whoami_get_parses_user_identity_with_bearer_auth() { - let server = MockServer::start().await; - let client = Client::with_bearer_token(server.uri(), "token"); - let wire = serde_json::json!({ - "actorType": "user", - "userId": "00000000-0000-4000-8000-000000000001", - "email": "user@example.com", - "name": "Example User", - "organizations": [ - { - "organizationId": "00000000-0000-4000-8000-000000000002", - "organizationName": "Example Org" - } - ] - }); - Mock::given(method("GET")) - .and(path("/v1/whoami")) - .and(bearer_token("token")) - .respond_with(ok_json(wire.clone())) - .expect(1) - .mount(&server) - .await; - let response = client.whoami_get().await.unwrap(); - assert_eq!(response.status, Some(200)); - let whoami = response.result.unwrap(); - let Whoami::WhoamiUser(user) = &whoami else { - panic!("expected a user identity, got {whoami:?}"); - }; - assert_eq!(user.actor_type, Some(WhoamiUserActortype::User)); - assert_eq!( - user.user_id, - Some(uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000001").unwrap()) - ); - assert_eq!(user.email.as_deref(), Some("user@example.com")); - assert_eq!(user.name.as_deref(), Some("Example User")); - let organizations = user.organizations.as_ref().unwrap(); - assert_eq!( - organizations[0].organization_id, - Some(uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000002").unwrap()) - ); - assert_eq!( - organizations[0].organization_name.as_deref(), - Some("Example Org") - ); - assert_eq!(serde_json::to_value(&whoami).unwrap(), wire); - - let requests = server.received_requests().await.unwrap(); - assert!(requests[0].url.query().is_none()); - assert!(requests[0].body.is_empty()); -} - -#[tokio::test] -async fn whoami_get_parses_api_key_identity_with_basic_auth() { - let (server, client) = setup().await; - let wire = serde_json::json!({ - "actorType": "apiKey", - "keyId": "key-1", - "name": "ci key", - "organizationId": "00000000-0000-4000-8000-000000000002" - }); - Mock::given(method("GET")) - .and(path("/v1/whoami")) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(wire.clone())) - .expect(1) - .mount(&server) - .await; - let whoami = client.whoami_get().await.unwrap().result.unwrap(); - let Whoami::WhoamiApiKey(key) = &whoami else { - panic!("expected an API key identity, got {whoami:?}"); - }; - assert_eq!(key.actor_type, Some(WhoamiApiKeyActortype::ApiKey)); - assert_eq!(key.key_id.as_deref(), Some("key-1")); - assert_eq!(key.name.as_deref(), Some("ci key")); - assert_eq!( - key.organization_id, - Some(uuid::Uuid::parse_str("00000000-0000-4000-8000-000000000002").unwrap()) - ); - assert_eq!(serde_json::to_value(&whoami).unwrap(), wire); -} - -#[tokio::test] -async fn whoami_get_keeps_unknown_actor_type_verbatim() { - let (server, client) = setup().await; - let wire = serde_json::json!({"actorType": "serviceAccount", "accountId": "sa-1"}); - Mock::given(method("GET")) - .and(path("/v1/whoami")) - .respond_with(ok_json(wire.clone())) - .expect(1) - .mount(&server) - .await; - let whoami = client.whoami_get().await.unwrap().result.unwrap(); - assert_eq!(whoami, Whoami::Unknown(wire)); -} - -#[tokio::test] -async fn whoami_get_propagates_api_errors() { - for (status, body, expected_message) in [ - ( - 401, - serde_json::json!({"status": 401, "error": "Unauthorized"}).to_string(), - "Unauthorized", - ), - (500, "upstream failed".to_owned(), "upstream failed"), - ] { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/whoami")) - .respond_with(ResponseTemplate::new(status).set_body_string(body)) - .expect(1) - .mount(&server) - .await; - let error = client.whoami_get().await.unwrap_err(); - assert!( - matches!(error, clickhouse_cloud_api::Error::Api { status: actual_status, ref message } if actual_status == status && message == expected_message), - "unexpected error: {error:?}" - ); - } -} - -// --------------------------------------------------------------------------- -// Notification types -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn list_notification_types() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/notificationTypes")) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!([ - { - "name": "service_backup_failed", - "title": "Backup failed", - "description": "A scheduled backup did not complete.", - "category": "service", - "severity": "warning", - "resourceType": "service", - "isConfigurable": true, - "isAvailable": true - }, - { - "name": "billing_new_tier", - "title": "New tier", - "category": "billing", - "severity": "page_someone", - "resourceType": "organization", - "isConfigurable": false, - "isAvailable": false - } - ]))) - .expect(1) - .mount(&s) - .await; - - let resp = c.notification_types_get_list("org-1").await.unwrap(); - let types = resp.result.unwrap(); - assert_eq!(types.len(), 2); - assert_eq!(types[0].name.as_deref(), Some("service_backup_failed")); - assert_eq!(types[0].title.as_deref(), Some("Backup failed")); - assert_eq!( - types[0].description.as_deref(), - Some("A scheduled backup did not complete.") - ); - assert_eq!(types[0].category.as_deref(), Some("service")); - assert_eq!(types[0].severity, Some(NotificationTypeSeverity::Warning)); - assert_eq!(types[0].resource_type.as_deref(), Some("service")); - assert_eq!(types[0].is_configurable, Some(true)); - assert_eq!(types[0].is_available, Some(true)); - assert_eq!(types[1].description, None); - assert_eq!( - types[1].severity, - Some(NotificationTypeSeverity::Unknown( - "page_someone".to_string() - )) - ); - assert_eq!(types[1].is_available, Some(false)); -} - -#[tokio::test] -async fn notification_types_get_list_surfaces_api_errors() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/notificationTypes")) - .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ - "status": 400, - "error": "Invalid organization" - }))) - .mount(&s) - .await; - - let err = c.notification_types_get_list("org-1").await.unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 400); - assert_eq!(message, "Invalid organization"); - } - other => panic!("unexpected error: {other:?}"), - } -} - -// --------------------------------------------------------------------------- -// Replica aware routing -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn get_replica_aware_routing() { - let (s, c) = setup().await; - - Mock::given(method("GET")) - .and(path( - "/v1/organizations/org-1/services/svc-1/replicaAwareRouting", - )) - .and(basic_auth("key", "secret")) - .respond_with(ok_json(serde_json::json!({ - "httpEnabled": false, - "nativeEnabled": true - }))) - .expect(1) - .mount(&s) - .await; - - let resp = c.replica_aware_routing_get("org-1", "svc-1").await.unwrap(); - let routing = resp.result.unwrap(); - assert_eq!(routing.http_enabled, Some(false)); - assert_eq!(routing.native_enabled, Some(true)); -} - -#[tokio::test] -async fn update_replica_aware_routing_sends_only_set_fields() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/replicaAwareRouting", - )) - .and(basic_auth("key", "secret")) - .and(body_json(serde_json::json!({"httpEnabled": true}))) - .respond_with(ok_json(serde_json::json!({ - "httpEnabled": true, - "nativeEnabled": false - }))) - .expect(1) - .mount(&s) - .await; - - let body = ReplicaAwareRoutingPatchRequest { - http_enabled: Some(true), - native_enabled: None, - }; - let resp = c - .replica_aware_routing_update("org-1", "svc-1", &body) - .await - .unwrap(); - let routing = resp.result.unwrap(); - assert_eq!(routing.http_enabled, Some(true)); - assert_eq!(routing.native_enabled, Some(false)); -} - -#[tokio::test] -async fn replica_aware_routing_update_surfaces_api_errors() { - let (s, c) = setup().await; - - Mock::given(method("PATCH")) - .and(path( - "/v1/organizations/org-1/services/svc-1/replicaAwareRouting", - )) - .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ - "status": 400, - "error": "Replica aware routing is not enabled for this organization" - }))) - .mount(&s) - .await; - - let body = ReplicaAwareRoutingPatchRequest { - http_enabled: None, - native_enabled: Some(true), - }; - let err = c - .replica_aware_routing_update("org-1", "svc-1", &body) - .await - .unwrap_err(); - match err { - clickhouse_cloud_api::Error::Api { status, message } => { - assert_eq!(status, 400); - assert_eq!( - message, - "Replica aware routing is not enabled for this organization" - ); - } - other => panic!("unexpected error: {other:?}"), - } -} - -#[tokio::test] -async fn notification_get_list_preserves_filters_cursor_and_envelope() { - let (server, client) = setup().await; - let cursor = "opaque +/=&?é"; - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/notifications")) - .and(basic_auth("key", "secret")) - .and(query_param("limit", "25")) - .and(query_param("cursor", cursor)) - .and(query_param("severity", "warning")) - .and(query_param("type", "service_backup_failed")) - .and(query_param("resource_id", "service-1")) - .and(query_param("resource_type", "service")) - .and(query_param("from_date", "2026-10-01T00:00:00+02:00")) - .and(query_param("to_date", "2026-10-08T00:00:00Z")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "status": 200, "requestId": "notification-page", "limit": 25, "nextCursor": "", - "result": [{ - "id": "1b7e0a94-2d0f-4c3a-8f1e-6c9b2d5a4e7f", - "type": "service_backup_failed", "severity": "warning", "resourceType": "service", - "resourceId": "service-1", "resourceName": "analytics", "createdAt": "2026-10-07T09:30:00Z", - "title": "Backup failed", "description": "Review backup settings.", "future": 42 - }] - }))) - .expect(1) - .mount(&server) - .await; - let response = client - .notification_get_list( - "org-1", - Some(25), - Some(cursor), - Some("warning"), - Some("service_backup_failed"), - Some("service-1"), - Some("service"), - Some("2026-10-01T00:00:00+02:00"), - Some("2026-10-08T00:00:00Z"), - ) - .await - .unwrap(); - assert_eq!(response.request_id.as_deref(), Some("notification-page")); - assert_eq!(response.limit, Some(25)); - assert_eq!(response.next_cursor.as_deref(), Some("")); - let notifications = response.result.unwrap(); - assert_eq!(notifications.len(), 1); - assert_eq!(notifications[0].title.as_deref(), Some("Backup failed")); - assert_eq!(notifications[0].resource_name.as_deref(), Some("analytics")); - assert!(notifications[0].created_at.is_some()); -} - -#[tokio::test] -async fn notification_get_list_tolerates_sparse_null_and_empty_pages() { - for body in [ - serde_json::json!({"result": [{}, {"id": null, "createdAt": null}], "nextCursor": null}), - serde_json::json!({"result": []}), - serde_json::json!({"result": null}), - serde_json::json!({}), - ] { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/notifications")) - .and(bearer_token("token")) - .respond_with(ResponseTemplate::new(200).set_body_json(body.clone())) - .expect(1) - .mount(&server) - .await; - let client = Client::with_bearer_token(server.uri(), "token"); - let response = client - .notification_get_list("org-1", None, None, None, None, None, None, None, None) - .await - .unwrap(); - assert_eq!(response.next_cursor, None); - if body["result"].is_array() { - assert_eq!( - response.result.as_ref().unwrap().len(), - body["result"].as_array().unwrap().len() - ); - } else { - assert_eq!(response.result, None); - } - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests[0].url.query(), None); - } -} - -#[tokio::test] -async fn notification_get_list_surfaces_api_errors() { - let (server, client) = setup().await; - Mock::given(method("GET")) - .and(path("/v1/organizations/org-1/notifications")) - .respond_with( - ResponseTemplate::new(400) - .set_body_json(serde_json::json!({"error": "invalid cursor"})), - ) - .mount(&server) - .await; - let error = client - .notification_get_list( - "org-1", - None, - Some("bad"), - None, - None, - None, - None, - None, - None, - ) - .await - .unwrap_err(); - assert!( - matches!(error, clickhouse_cloud_api::Error::Api { status: 400, message } if message == "invalid cursor") - ); -} diff --git a/scripts/classify-cloud-integration.py b/scripts/classify-cloud-integration.py index a32777b4..f6837391 100644 --- a/scripts/classify-cloud-integration.py +++ b/scripts/classify-cloud-integration.py @@ -160,6 +160,23 @@ "crates/clickhouse-cloud-api/tests/clickpipes/support.rs": frozenset( {"clickpipes"} ), + "crates/clickhouse-cloud-api/tests/client/activity.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/api_keys.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/backups.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/byoc.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/clickpipes.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/clickstack.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/client.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/invitations.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/main.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/members.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/organizations.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/postgres.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/query_api_endpoints.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/saved_queries.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/services.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/udfs.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/client/whoami.rs": NO_SUITES, "crates/clickhouse-cloud-api/tests/client_test.rs": NO_SUITES, "crates/clickhouse-cloud-api/tests/common/mod.rs": ALL_SUITES, "crates/clickhouse-cloud-api/tests/common/support.rs": ALL_SUITES, diff --git a/scripts/tests/test_classify_cloud_integration.py b/scripts/tests/test_classify_cloud_integration.py index 015dd47d..2ad3a6f7 100644 --- a/scripts/tests/test_classify_cloud_integration.py +++ b/scripts/tests/test_classify_cloud_integration.py @@ -209,7 +209,23 @@ def test_current_test_mapping_values(self): "crates/clickhouse-cloud-api/tests/clickpipes/stages/redpanda_user_data_mtls.sh.template", "crates/clickhouse-cloud-api/tests/clickpipes/stages/redpanda_user_data_scram_tls.sh.template", "crates/clickhouse-cloud-api/tests/clickpipes/stages/s3.rs", - "crates/clickhouse-cloud-api/tests/client_test.rs", + "crates/clickhouse-cloud-api/tests/client/activity.rs", + "crates/clickhouse-cloud-api/tests/client/api_keys.rs", + "crates/clickhouse-cloud-api/tests/client/backups.rs", + "crates/clickhouse-cloud-api/tests/client/byoc.rs", + "crates/clickhouse-cloud-api/tests/client/clickpipes.rs", + "crates/clickhouse-cloud-api/tests/client/clickstack.rs", + "crates/clickhouse-cloud-api/tests/client/client.rs", + "crates/clickhouse-cloud-api/tests/client/invitations.rs", + "crates/clickhouse-cloud-api/tests/client/main.rs", + "crates/clickhouse-cloud-api/tests/client/members.rs", + "crates/clickhouse-cloud-api/tests/client/organizations.rs", + "crates/clickhouse-cloud-api/tests/client/postgres.rs", + "crates/clickhouse-cloud-api/tests/client/query_api_endpoints.rs", + "crates/clickhouse-cloud-api/tests/client/saved_queries.rs", + "crates/clickhouse-cloud-api/tests/client/services.rs", + "crates/clickhouse-cloud-api/tests/client/udfs.rs", + "crates/clickhouse-cloud-api/tests/client/whoami.rs", "crates/clickhouse-cloud-api/tests/model_facade_test.rs", "crates/clickhouse-cloud-api/tests/models/activity.rs", "crates/clickhouse-cloud-api/tests/models/api_keys.rs",