From ccf040c026cc57dad716519aa2d13e90c11c343d Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 30 Oct 2026 20:04:10 +0100 Subject: [PATCH 1/5] refactor(api): move tests/client_test.rs to tests/client/main.rs (#1173) Pure rename so blame follows the file. An explicit [[test]] target keeps the client_test name; cargo does not also infer a second `client` target for the same path. Co-Authored-By: Claude Opus 5.5 --- crates/clickhouse-cloud-api/Cargo.toml | 9 +++++++-- .../tests/{client_test.rs => client/main.rs} | 0 2 files changed, 7 insertions(+), 2 deletions(-) rename crates/clickhouse-cloud-api/tests/{client_test.rs => client/main.rs} (100%) 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_test.rs b/crates/clickhouse-cloud-api/tests/client/main.rs similarity index 100% rename from crates/clickhouse-cloud-api/tests/client_test.rs rename to crates/clickhouse-cloud-api/tests/client/main.rs From 00b469bdbc23f95f27d97f953e6911d3e523db63 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 30 Oct 2026 20:05:53 +0100 Subject: [PATCH 2/5] refactor(api): split client_test into per-domain modules (#1173) tests/client/main.rs keeps the module declarations and the shared wiremock helpers (ok_json, ok_empty, created_json, setup); each test moves unchanged to the module named after the src/client/ file that defines the Client method it calls. client.rs takes the construction, authentication, base URL and error-handling tests, plus the one test that spans several domains. Every section divider survives once; where a section's tests now land in several modules, its divider stays with the module that receives the section's first test. Still one test binary. Co-Authored-By: Claude Opus 5.5 --- .../tests/client/activity.rs | 89 + .../tests/client/api_keys.rs | 243 + .../tests/client/backups.rs | 607 ++ .../clickhouse-cloud-api/tests/client/byoc.rs | 465 ++ .../tests/client/clickpipes.rs | 634 ++ .../tests/client/clickstack.rs | 1196 +++ .../tests/client/client.rs | 417 + .../tests/client/invitations.rs | 98 + .../clickhouse-cloud-api/tests/client/main.rs | 7138 +---------------- .../tests/client/members.rs | 109 + .../tests/client/organizations.rs | 661 ++ .../tests/client/postgres.rs | 648 ++ .../tests/client/query_api_endpoints.rs | 243 + .../tests/client/saved_queries.rs | 286 + .../tests/client/services.rs | 859 ++ .../clickhouse-cloud-api/tests/client/udfs.rs | 509 ++ .../tests/client/whoami.rs | 130 + 17 files changed, 7213 insertions(+), 7119 deletions(-) create mode 100644 crates/clickhouse-cloud-api/tests/client/activity.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/api_keys.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/backups.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/byoc.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/clickpipes.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/clickstack.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/client.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/invitations.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/members.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/organizations.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/postgres.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/query_api_endpoints.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/saved_queries.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/services.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/udfs.rs create mode 100644 crates/clickhouse-cloud-api/tests/client/whoami.rs 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 index 2797ed8a..be260082 100644 --- a/crates/clickhouse-cloud-api/tests/client/main.rs +++ b/crates/clickhouse-cloud-api/tests/client/main.rs @@ -1,9 +1,22 @@ -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}; +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 @@ -37,7116 +50,3 @@ async fn setup() -> (MockServer, Client) { 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/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:?}" + ); + } +} From c970c40411c60a02e035061a95b0caa725fcf3bc Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 30 Oct 2026 20:06:26 +0100 Subject: [PATCH 3/5] ci: classify the split tests/client/ files (#1173) Each tests/client/*.rs module maps to no live Cloud suite, like tests/client_test.rs did: the client_test binary is offline wiremock coverage and none of the live integration targets include it. The old path stays mapped so diffs that name it remain classified. The install classifier inventories only crates/clickhousectl, so it needs no entry. Co-Authored-By: Claude Opus 5.5 --- scripts/classify-cloud-integration.py | 17 +++++++++++++++++ .../tests/test_classify_cloud_integration.py | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) 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", From 10c8be77f813e62ab1f7adaf0cbb78060952da1d Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 30 Oct 2026 20:06:50 +0100 Subject: [PATCH 4/5] chore: ignore the tests/client_test.rs split in git blame (#1173) Co-Authored-By: Claude Opus 5.5 --- .git-blame-ignore-revs | 3 +++ 1 file changed, 3 insertions(+) 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 From f8cc2a5d3f4053b69a256de510a32b7cac199de7 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 30 Oct 2026 20:07:00 +0100 Subject: [PATCH 5/5] docs(api): describe the tests/client/ layout (#1173) Co-Authored-By: Claude Opus 5.5 --- crates/clickhouse-cloud-api/AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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