Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion crates/clickhouse-cloud-api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions crates/clickhouse-cloud-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
89 changes: 89 additions & 0 deletions crates/clickhouse-cloud-api/tests/client/activity.rs
Original file line number Diff line number Diff line change
@@ -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());
}
243 changes: 243 additions & 0 deletions crates/clickhouse-cloud-api/tests/client/api_keys.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<(String, String)>> = 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"));
}
}
Loading
Loading