diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 2e1f7bb6..511785f3 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -19,3 +19,6 @@ e7fcdb1a8f1e48c564717f1ccd0fa654d182aa73 # Split client/organizations.rs into byoc, invitations, members and rbac (#1173) 9e20dd888a937f5f0b610b22a71aef4ff70b9357 + +# Split tests/models_test.rs into per-domain tests/models/ modules (#1173) +bb5834b93b5c9d6640c7720991aaef9f750a61ef diff --git a/crates/clickhouse-cloud-api/AGENTS.md b/crates/clickhouse-cloud-api/AGENTS.md index 28926691..7a0c7602 100644 --- a/crates/clickhouse-cloud-api/AGENTS.md +++ b/crates/clickhouse-cloud-api/AGENTS.md @@ -154,8 +154,10 @@ 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_test.rs`, `tests/model_facade_test.rs`, `tests/run_query_test.rs`, +- `tests/client_test.rs`, `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/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 Postgres inside ClickHouse Cloud) runs in CI; its `clickpipe_postgres_cli_cdc_test` target exercises the built `clickhousectl` supplied through `CLICKHOUSE_CLOUD_TEST_CLICKHOUSECTL_BIN`, which diff --git a/crates/clickhouse-cloud-api/Cargo.toml b/crates/clickhouse-cloud-api/Cargo.toml index 63cd0fdf..91d5ce67 100644 --- a/crates/clickhouse-cloud-api/Cargo.toml +++ b/crates/clickhouse-cloud-api/Cargo.toml @@ -52,6 +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. + +[[test]] +name = "models_test" +path = "tests/models/main.rs" + # ClickPipes E2E test binaries live under tests/clickpipes/. Cargo only # auto-discovers .rs files directly under tests/, so each one is declared here. diff --git a/crates/clickhouse-cloud-api/tests/models/activity.rs b/crates/clickhouse-cloud-api/tests/models/activity.rs new file mode 100644 index 00000000..2b4e9dc1 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/activity.rs @@ -0,0 +1,99 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn deserialize_activity() { + let json = r#"{ + "actorType": "api", + "actorId": "actor-123", + "createdAt": "2024-06-01T00:00:00Z" + }"#; + let activity: Activity = serde_json::from_str(json).unwrap(); + assert_eq!(activity.actor_type, Some(ActivityActortype::Api)); +} + +#[test] +fn activity_ignores_extra_fields() { + let json = r#"{"actorType":"user","sourceIp":"1.2.3.4"}"#; + let a: Activity = serde_json::from_str(json).unwrap(); + assert_eq!(a.actor_type, Some(ActivityActortype::User)); +} + +#[test] +fn activity_type_new_wire_values_deserialize_to_typed_variants() { + let cases = [ + ( + "organization_member_update_roles", + ActivityType::Organization_member_update_roles, + ), + ( + "organization_saml_connection_create", + ActivityType::Organization_saml_connection_create, + ), + ( + "organization_saml_connection_update", + ActivityType::Organization_saml_connection_update, + ), + ( + "service_update_snapshot_configuration", + ActivityType::Service_update_snapshot_configuration, + ), + ("backup_bucket_create", ActivityType::Backup_bucket_create), + ("role_update", ActivityType::Role_update), + ("service_mcp_enabled", ActivityType::Service_mcp_enabled), + ("udf_create", ActivityType::Udf_create), + ]; + for (wire, expected) in cases { + let parsed: ActivityType = serde_json::from_str(&format!("\"{wire}\"")).unwrap(); + assert_eq!(parsed, expected, "{wire} should not be Unknown"); + assert_eq!(parsed.to_string(), wire); + assert_eq!(serde_json::to_value(&parsed).unwrap(), wire); + } +} + +#[test] +fn activity_type_approved_domain_and_org_update_values_round_trip() { + let cases = [ + ( + "organization_approved_domain_auto_invite", + ActivityType::Organization_approved_domain_auto_invite, + ), + ( + "organization_approved_domain_auto_join", + ActivityType::Organization_approved_domain_auto_join, + ), + ( + "organization_approved_domain_delete", + ActivityType::Organization_approved_domain_delete, + ), + ( + "organization_approved_domain_update", + ActivityType::Organization_approved_domain_update, + ), + ( + "organization_approved_domain_verify", + ActivityType::Organization_approved_domain_verify, + ), + ( + "organization_update_public_preview", + ActivityType::Organization_update_public_preview, + ), + ( + "organization_update_saml_query_ownership_migration", + ActivityType::Organization_update_saml_query_ownership_migration, + ), + ]; + for (wire, expected) in cases { + let parsed: ActivityType = serde_json::from_str(&format!("\"{wire}\"")).unwrap(); + assert_eq!(parsed, expected, "{wire} should not be Unknown"); + assert_eq!(parsed.to_string(), wire); + assert_eq!(serde_json::to_value(&parsed).unwrap(), wire); + } +} + +#[test] +fn activity_type_postgres_action_round_trips() { + let parsed: ActivityType = serde_json::from_str("\"postgres_action\"").unwrap(); + assert_eq!(parsed, ActivityType::Postgres_action); + assert_eq!(parsed.to_string(), "postgres_action"); + assert_eq!(serde_json::to_value(&parsed).unwrap(), "postgres_action"); +} diff --git a/crates/clickhouse-cloud-api/tests/models/api_keys.rs b/crates/clickhouse-cloud-api/tests/models/api_keys.rs new file mode 100644 index 00000000..98638b2e --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/api_keys.rs @@ -0,0 +1,114 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn deserialize_api_key() { + let json = r#"{ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "My API Key", + "state": "enabled", + "roles": ["admin"], + "createdAt": "2024-01-01T00:00:00Z", + "expireAt": "2025-01-01T00:00:00Z" + }"#; + let key: ApiKey = serde_json::from_str(json).unwrap(); + assert_eq!(key.name.as_deref(), Some("My API Key")); + assert_eq!(key.state, Some(ApiKeyState::Enabled)); +} + +#[test] +fn serialize_api_key_post_request() { + let req = ApiKeyPostRequest { + name: "test-key".to_string(), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["name"], "test-key"); +} + +#[test] +fn api_key_ignores_extra_fields() { + let json = r#"{"name":"key","state":"enabled","rotationPolicy":"weekly"}"#; + let key: ApiKey = serde_json::from_str(json).unwrap(); + assert_eq!(key.name.as_deref(), Some("key")); + assert_eq!(key.state, Some(ApiKeyState::Enabled)); +} + +#[test] +fn api_key_minimal_response() { + let k: ApiKey = serde_json::from_str(r#"{"name":"k"}"#).unwrap(); + assert_eq!(k.name.as_deref(), Some("k")); + assert_eq!(k.id, None); + assert_eq!(k.state, None); +} + +#[test] +fn api_key_patch_expiry_preserves_omit_set_and_clear() { + let timestamp = chrono::DateTime::parse_from_rfc3339("2030-01-02T03:04:05Z") + .unwrap() + .with_timezone(&chrono::Utc); + for (expire_at, wire) in [ + (None, serde_json::json!({})), + ( + Some(Some(timestamp)), + serde_json::json!({"expireAt": "2030-01-02T03:04:05Z"}), + ), + (Some(None), serde_json::json!({"expireAt": null})), + ] { + let request = ApiKeyPatchRequest { + expire_at, + ..Default::default() + }; + assert_eq!(serde_json::to_value(&request).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + request + ); + } +} + +#[test] +fn api_key_patch_deserialization_preserves_other_fields_and_ignores_unknowns() { + let wire = serde_json::json!({ + "expireAt": null, + "assignedRoleIds": ["aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"], + "ipAccessList": [{"source": "10.0.0.0/8", "description": "private"}], + "name": "renamed", + "state": "disabled" + }); + let mut extended = wire.clone(); + extended["newServerField"] = serde_json::json!({"nested": true}); + let request: ApiKeyPatchRequest = serde_json::from_value(extended).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), wire); + let nulls: ApiKeyPatchRequest = serde_json::from_value(serde_json::json!({ + "assignedRoleIds": null, "ipAccessList": null, "name": null, "state": null + })) + .unwrap(); + assert_eq!(nulls, ApiKeyPatchRequest::default()); +} + +#[test] +fn api_key_patch_deserialization_rejects_invalid_recognized_fields() { + for wire in [ + serde_json::json!({"expireAt": "tomorrow"}), + serde_json::json!({"expireAt": 42}), + serde_json::json!({"assignedRoleIds": ["invalid"]}), + serde_json::json!({"ipAccessList": [{}]}), + serde_json::json!({"name": false}), + serde_json::json!({"state": {}}), + serde_json::json!(null), + serde_json::json!([]), + ] { + assert!( + serde_json::from_value::(wire.clone()).is_err(), + "accepted {wire}" + ); + } +} + +#[cfg(feature = "deprecated-fields")] +#[test] +fn api_key_patch_deserialization_preserves_deprecated_roles() { + let wire = serde_json::json!({"expireAt": null, "roles": ["admin"]}); + let request: ApiKeyPatchRequest = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), wire); +} diff --git a/crates/clickhouse-cloud-api/tests/models/backups.rs b/crates/clickhouse-cloud-api/tests/models/backups.rs new file mode 100644 index 00000000..93a31634 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/backups.rs @@ -0,0 +1,426 @@ +use clickhouse_cloud_api::models::*; + +use crate::assert_unknown_variant_round_trips; + +#[test] +fn aws_backup_external_id_response_tolerates_absence_and_null() { + for wire in [ + serde_json::json!({}), + serde_json::json!({"iamRoleExternalId": null}), + ] { + let response: AwsBackupBucket = serde_json::from_value(wire).unwrap(); + assert_eq!(response.iam_role_external_id, None); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({}) + ); + } + let response: AwsBackupBucket = serde_json::from_value( + serde_json::json!({"iamRoleExternalId": "trust-policy-id", "futureField": 1}), + ) + .unwrap(); + assert_eq!( + response.iam_role_external_id.as_deref(), + Some("trust-policy-id") + ); +} + +#[test] +fn aws_backup_external_id_create_and_properties_are_strict() { + let mut wire = serde_json::json!({ + "bucketProvider": "AWS", "bucketPath": "s3://backups", + "iamRoleArn": "arn:aws:iam::123:role/backup", "iamRoleSessionName": "backups", + "iamRoleExternalId": "trust-policy-id" + }); + let request: AwsBackupBucketPostRequestV1 = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(request.iam_role_external_id, "trust-policy-id"); + assert_eq!(serde_json::to_value(request).unwrap(), wire); + let properties: AwsBackupBucketProperties = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(properties.iam_role_external_id, "trust-policy-id"); + wire.as_object_mut().unwrap().remove("iamRoleExternalId"); + assert!(serde_json::from_value::(wire.clone()).is_err()); + assert!(serde_json::from_value::(wire.clone()).is_err()); + wire["iamRoleExternalId"] = serde_json::Value::Null; + assert!(serde_json::from_value::(wire.clone()).is_err()); + assert!(serde_json::from_value::(wire).is_err()); +} + +#[test] +fn aws_backup_external_id_update_preserves_omit_set_and_clear() { + for external_id in [ + None, + Some(serde_json::json!("trust-policy-id")), + Some(serde_json::Value::Null), + ] { + let mut wire = serde_json::json!({ + "bucketProvider": "AWS", "bucketPath": "s3://backups", + "iamRoleArn": "arn:aws:iam::123:role/backup" + }); + if let Some(value) = &external_id { + wire["iamRoleExternalId"] = value.clone(); + } + let request: AwsBackupBucketPatchRequestV1 = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + request.iam_role_external_id, + external_id.map(|value| value.as_str().map(str::to_owned)) + ); + assert_eq!(serde_json::to_value(request).unwrap(), wire); + } + for wire in [ + r#"{"bucketProvider":"AWS","bucketPath":"s3://backups","iamRoleArn":"arn","iamRoleExternalId":42}"#, + r#"{"bucketProvider":"AWS","bucketPath":"s3://backups","iamRoleArn":"arn","iamRoleExternalId":null,"iamRoleExternalId":"id"}"#, + ] { + assert!(serde_json::from_str::(wire).is_err()); + } + assert!( + serde_json::from_value::(serde_json::json!({})).is_err() + ); +} + +#[test] +fn snapshot_configuration_tolerates_missing_null_and_extra_fields() { + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "enabled": null, "gap": null, "timeFrame": null, "futureProperty": 1 + }), + ] { + let configuration: SnapshotConfiguration = serde_json::from_value(wire).unwrap(); + assert_eq!(configuration, SnapshotConfiguration::default()); + assert_eq!( + serde_json::to_value(configuration).unwrap(), + serde_json::json!({}) + ); + } +} + +#[test] +fn snapshot_configuration_preserves_false_zero_and_fractional_numbers() { + let configuration: SnapshotConfiguration = serde_json::from_value(serde_json::json!({ + "enabled": false, "gap": 0, "timeFrame": 1440.5 + })) + .unwrap(); + assert_eq!(configuration.enabled, Some(false)); + assert_eq!(configuration.gap, Some(0.0)); + assert_eq!(configuration.time_frame, Some(1440.5)); + assert_eq!( + serde_json::to_value(configuration).unwrap(), + serde_json::json!({"enabled": false, "gap": 0.0, "timeFrame": 1440.5}) + ); +} + +#[test] +fn snapshot_configuration_patch_omits_none_and_preserves_provided_values() { + for (request, expected) in [ + ( + SnapshotConfigurationPatchRequest::default(), + serde_json::json!({}), + ), + ( + SnapshotConfigurationPatchRequest { + enabled: Some(false), + ..Default::default() + }, + serde_json::json!({"enabled": false}), + ), + ( + SnapshotConfigurationPatchRequest { + enabled: None, + gap: Some(0.0), + time_frame: Some(1440.5), + }, + serde_json::json!({"gap": 0.0, "timeFrame": 1440.5}), + ), + ( + SnapshotConfigurationPatchRequest { + enabled: Some(true), + gap: Some(30.0), + time_frame: Some(1440.0), + }, + serde_json::json!({"enabled": true, "gap": 30.0, "timeFrame": 1440.0}), + ), + ] { + let serialized = serde_json::to_value(&request).unwrap(); + assert_eq!(serialized, expected); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + request + ); + } +} + +#[test] +fn snapshot_round_trips_complete_response_and_unknown_bucket_properties() { + let value = serde_json::json!({ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "status": "done", "type": "full", "serviceId": "svc-1", + "startedAt": "2026-09-15T01:00:00Z", "finishedAt": "2026-09-15T01:00:01Z", + "sizeInBytes": 2048.0, "durationInSeconds": 1.5, "backupName": "snapshot-1", + "bucket": {"bucketProvider": "FUTURE", "newProperty": {"region": "test"}} + }); + let snapshot: Snapshot = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(snapshot.status, Some(SnapshotStatus::Done)); + assert_eq!(snapshot.r#type, Some(SnapshotType::Full)); + assert_eq!(snapshot.duration_in_seconds, Some(1.5)); + assert_eq!(serde_json::to_value(snapshot).unwrap(), value); +} + +#[test] +fn snapshot_tolerates_missing_null_and_extra_fields() { + let dropped: Snapshot = serde_json::from_str("{}").unwrap(); + let nulled: Snapshot = serde_json::from_value(serde_json::json!({ + "id": null, "status": null, "type": null, "serviceId": null, + "startedAt": null, "finishedAt": null, "sizeInBytes": null, + "durationInSeconds": null, "backupName": null, "bucket": null, + "futureProperty": 1 + })) + .unwrap(); + assert_eq!(dropped, Snapshot::default()); + assert_eq!(nulled, Snapshot::default()); + assert_eq!(serde_json::to_value(nulled).unwrap(), serde_json::json!({})); +} + +#[test] +fn snapshot_enums_preserve_known_and_unknown_values() { + for (wire, expected) in [ + ("done", SnapshotStatus::Done), + ("error", SnapshotStatus::Error), + ("in_progress", SnapshotStatus::InProgress), + ("throttled", SnapshotStatus::Throttled), + ("queued", SnapshotStatus::Unknown("queued".to_string())), + ] { + let parsed: SnapshotStatus = serde_json::from_value(serde_json::json!(wire)).unwrap(); + assert_eq!(parsed, expected); + assert_eq!(parsed.to_string(), wire); + assert_eq!(serde_json::to_value(parsed).unwrap(), wire); + } + for (wire, expected) in [ + ("full", SnapshotType::Full), + ("future", SnapshotType::Unknown("future".to_string())), + ] { + let parsed: SnapshotType = serde_json::from_value(serde_json::json!(wire)).unwrap(); + assert_eq!(parsed, expected); + assert_eq!(parsed.to_string(), wire); + assert_eq!(serde_json::to_value(parsed).unwrap(), wire); + } +} + +#[test] +fn deserialize_backup() { + let json = r#"{ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "status": "done", + "serviceId": "11111111-2222-3333-4444-555555555555", + "startedAt": "2024-06-01T02:00:00Z", + "finishedAt": "2024-06-01T02:05:00Z", + "sizeInBytes": 1073741824, + "durationInSeconds": 300, + "type": "full", + "backupName": "backup-2024-06-01" + }"#; + let backup: Backup = serde_json::from_str(json).unwrap(); + assert_eq!(backup.status, Some(BackupStatus::Done)); + assert_eq!(backup.r#type, Some(BackupType::Full)); + assert_eq!(backup.size_in_bytes, Some(1073741824.0)); + assert_eq!(backup.duration_in_seconds, Some(300.0)); +} + +#[test] +fn deserialize_backup_configuration() { + let json = r#"{ + "backupPeriodInHours": 24, + "backupRetentionPeriodInHours": 168, + "backupStartTime": "02:00" + }"#; + let config: BackupConfiguration = serde_json::from_str(json).unwrap(); + assert_eq!(config.backup_period_in_hours, Some(24.0)); + assert_eq!(config.backup_retention_period_in_hours, Some(168.0)); + assert_eq!(config.backup_start_time.as_deref(), Some("02:00")); +} + +#[test] +fn serialize_backup_configuration_patch_request() { + let req = 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 json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["backupPeriodInHours"], 12.0); + assert_eq!(json["backupRetentionPeriodInHours"], 336.0); + assert_eq!(json["backupStartTime"], "03:00"); +} + +/// The three states of `backupStartTime` are what let a caller both set and +/// unset the start time: only an explicit `null` clears the stored value, and +/// omitting the key must leave it untouched. +#[test] +fn serialize_backup_configuration_patch_request_omits_an_untouched_start_time() { + let req = BackupConfigurationPatchRequest { + backup_period_in_hours: Some(12.0), + backup_retention_period_in_hours: None, + backup_start_time: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json, serde_json::json!({ "backupPeriodInHours": 12.0 })); +} + +#[test] +fn serialize_backup_configuration_patch_request_clears_the_start_time_with_null() { + let req = BackupConfigurationPatchRequest { + backup_period_in_hours: Some(12.0), + backup_retention_period_in_hours: None, + backup_start_time: Some(None), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!( + json, + serde_json::json!({ "backupPeriodInHours": 12.0, "backupStartTime": null }) + ); +} + +#[test] +fn backup_ignores_extra_fields() { + let json = r#"{"status":"done","type":"full","compressionRatio":0.85}"#; + let backup: Backup = serde_json::from_str(json).unwrap(); + assert_eq!(backup.status, Some(BackupStatus::Done)); +} + +#[test] +fn backup_configuration_ignores_extra_fields() { + let json = r#"{"backupPeriodInHours":24,"backupRetentionPeriodInHours":168,"compressionEnabled":true}"#; + let c: BackupConfiguration = serde_json::from_str(json).unwrap(); + assert_eq!(c.backup_period_in_hours, Some(24.0)); +} + +#[test] +fn backup_minimal_response() { + let b: Backup = serde_json::from_str("{}").unwrap(); + assert_eq!(b.id, None); + assert_eq!(b.status, None); + assert_eq!(b.size_in_bytes, None); +} + +#[test] +fn deserialize_aws_backup_bucket() { + let json = r#"{ + "bucketPath": "s3://my-bucket/prefix", + "bucketProvider": "AWS", + "iamRoleArn": "arn:aws:iam::123:role/backup", + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }"#; + let b: AwsBackupBucket = serde_json::from_str(json).unwrap(); + assert_eq!(b.bucket_path.as_deref(), Some("s3://my-bucket/prefix")); + assert_eq!( + b.iam_role_arn.as_deref(), + Some("arn:aws:iam::123:role/backup") + ); +} + +#[test] +fn deserialize_backup_bucket_dispatches_aws() { + let json = r#"{ + "bucketPath": "s3://my-bucket/prefix", + "bucketProvider": "AWS", + "iamRoleArn": "arn:aws:iam::123:role/backup", + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }"#; + let b: BackupBucket = serde_json::from_str(json).unwrap(); + assert!(matches!(b, BackupBucket::AwsBackupBucket(_))); + if let BackupBucket::AwsBackupBucket(aws) = b { + assert_eq!(aws.bucket_path.as_deref(), Some("s3://my-bucket/prefix")); + assert_eq!( + aws.iam_role_arn.as_deref(), + Some("arn:aws:iam::123:role/backup") + ); + } +} + +#[test] +fn deserialize_backup_bucket_dispatches_gcp() { + let json = r#"{ + "accessKeyId": "GOOG1234567890", + "bucketPath": "gs://my-gcp-bucket/prefix", + "bucketProvider": "GCP", + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }"#; + let b: BackupBucket = serde_json::from_str(json).unwrap(); + assert!(matches!(b, BackupBucket::GcpBackupBucket(_))); + if let BackupBucket::GcpBackupBucket(gcp) = b { + assert_eq!(gcp.access_key_id.as_deref(), Some("GOOG1234567890")); + assert_eq!( + gcp.bucket_path.as_deref(), + Some("gs://my-gcp-bucket/prefix") + ); + } +} + +#[test] +fn deserialize_backup_bucket_dispatches_azure() { + let json = r#"{ + "bucketProvider": "AZURE", + "containerName": "my-container", + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }"#; + let b: BackupBucket = serde_json::from_str(json).unwrap(); + assert!(matches!(b, BackupBucket::AzureBackupBucket(_))); + if let BackupBucket::AzureBackupBucket(azure) = b { + assert_eq!(azure.container_name.as_deref(), Some("my-container")); + } +} + +#[test] +fn deserialize_backup_bucket_unknown_provider() { + // The Unknown payload round-trips losslessly as the original object, not a + // JSON string. + let json = r#"{ + "bucketProvider": "NEW_PROVIDER", + "somefield": "somevalue", + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }"#; + assert_unknown_variant_round_trips(json, |b: &BackupBucket| { + matches!(b, BackupBucket::Unknown(_)) + }); +} + +#[test] +fn backup_bucket_patch_request_unknown_provider_round_trips() { + let json = r#"{ + "bucketProvider": "NEW_PROVIDER", + "somefield": "somevalue" + }"#; + assert_unknown_variant_round_trips(json, |b: &BackupBucketPatchRequest| { + matches!(b, BackupBucketPatchRequest::Unknown(_)) + }); +} + +#[test] +fn backup_bucket_post_request_unknown_provider_round_trips() { + let json = r#"{ + "bucketProvider": "NEW_PROVIDER", + "somefield": "somevalue" + }"#; + assert_unknown_variant_round_trips(json, |b: &BackupBucketPostRequest| { + matches!(b, BackupBucketPostRequest::Unknown(_)) + }); +} + +#[test] +fn backup_bucket_properties_unknown_provider_round_trips() { + let json = r#"{ + "bucketProvider": "NEW_PROVIDER", + "somefield": "somevalue" + }"#; + assert_unknown_variant_round_trips(json, |b: &BackupBucketProperties| { + matches!(b, BackupBucketProperties::Unknown(_)) + }); +} + +#[test] +fn backup_bucket_unknown_display_emits_compact_json() { + let json = r#"{"bucketProvider":"NEW_PROVIDER","somefield":"somevalue"}"#; + let b: BackupBucket = serde_json::from_str(json).unwrap(); + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + // Display emits the raw compact-JSON payload carried by the Unknown variant. + assert_eq!(b.to_string(), value.to_string()); +} diff --git a/crates/clickhouse-cloud-api/tests/models/byoc.rs b/crates/clickhouse-cloud-api/tests/models/byoc.rs new file mode 100644 index 00000000..e400e907 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/byoc.rs @@ -0,0 +1,468 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn serialize_byoc_infrastructure_post_request() { + let req = ByocInfrastructurePostRequest { + account_id: "123456789012".to_string(), + availability_zone_suffixes: Some(vec![ + ByocAvailabilityZoneSuffix::A, + ByocAvailabilityZoneSuffix::B, + ByocAvailabilityZoneSuffix::C, + ByocAvailabilityZoneSuffix::D, + ByocAvailabilityZoneSuffix::E, + ByocAvailabilityZoneSuffix::F, + ]), + display_name: Some("My BYOC".to_string()), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["accountId"], "123456789012"); + assert_eq!( + json["availabilityZoneSuffixes"], + serde_json::json!(["a", "b", "c", "d", "e", "f"]) + ); + assert_eq!(json["displayName"], "My BYOC"); +} + +#[test] +fn byoc_infrastructure_post_request_requires_only_account_and_region() { + // Compile-time strictness: accountId and regionId are the spec's only required fields. + let req = ByocInfrastructurePostRequest { + account_id: "123456789012".to_string(), + region_id: ByocInfrastructurePostRequestRegionid::Eastus, + availability_zone_suffixes: None, + display_name: None, + external_id: None, + gcp_pod_cidr_range_names: None, + gcp_shared_vpc_host_project_id: None, + private_subnet_ids: None, + public_subnet_ids: None, + service_principal_client_id: None, + tags: None, + tenant_id: None, + vpc_cidr_range: None, + vpc_id: None, + }; + assert_eq!( + serde_json::to_value(&req).unwrap(), + serde_json::json!({"accountId": "123456789012", "regionId": "eastus"}) + ); +} + +#[test] +fn byoc_infrastructure_post_request_serializes_every_optional_field() { + let req = ByocInfrastructurePostRequest { + account_id: "project-1".to_string(), + region_id: ByocInfrastructurePostRequestRegionid::Us_central1, + availability_zone_suffixes: Some(vec![ByocAvailabilityZoneSuffix::A]), + display_name: Some("gcp".to_string()), + external_id: Some("ext".to_string()), + gcp_pod_cidr_range_names: Some(vec!["pods".to_string()]), + gcp_shared_vpc_host_project_id: Some("host".to_string()), + private_subnet_ids: Some(vec!["private".to_string()]), + public_subnet_ids: Some(vec!["public".to_string()]), + service_principal_client_id: Some("sp".to_string()), + tags: Some(ByocInfrastructureTags::from([( + "env".to_string(), + "prod".to_string(), + )])), + tenant_id: Some("tenant".to_string()), + vpc_cidr_range: Some("10.0.0.0/16".to_string()), + vpc_id: Some("network".to_string()), + }; + assert_eq!( + serde_json::to_value(&req).unwrap(), + serde_json::json!({ + "accountId": "project-1", + "regionId": "us-central1", + "availabilityZoneSuffixes": ["a"], + "displayName": "gcp", + "externalId": "ext", + "gcpPodCidrRangeNames": ["pods"], + "gcpSharedVpcHostProjectId": "host", + "privateSubnetIds": ["private"], + "publicSubnetIds": ["public"], + "servicePrincipalClientId": "sp", + "tags": {"env": "prod"}, + "tenantId": "tenant", + "vpcCidrRange": "10.0.0.0/16", + "vpcId": "network" + }) + ); +} + +#[test] +fn byoc_infrastructure_validate_post_request_is_strict_on_account_and_region() { + let req = ByocInfrastructureValidatePostRequest { + account_id: "sub-1".to_string(), + region_id: ByocInfrastructureValidatePostRequestRegionid::Westus3, + availability_zone_suffixes: None, + external_id: None, + gcp_pod_cidr_range_names: None, + gcp_shared_vpc_host_project_id: None, + private_subnet_ids: None, + public_subnet_ids: None, + service_principal_client_id: Some("sp".to_string()), + tags: None, + tenant_id: Some("tenant".to_string()), + vpc_cidr_range: None, + vpc_id: None, + }; + assert_eq!( + serde_json::to_value(&req).unwrap(), + serde_json::json!({ + "accountId": "sub-1", + "regionId": "westus3", + "servicePrincipalClientId": "sp", + "tenantId": "tenant" + }) + ); +} + +#[test] +fn byoc_infrastructure_tags_preserve_dynamic_keys() { + let tags: ByocInfrastructureTags = + serde_json::from_str(r#"{"team":"data","kubernetes.io/cluster":"owned","":"empty"}"#) + .unwrap(); + assert_eq!(tags.len(), 3); + assert_eq!(tags["team"], "data"); + assert_eq!(tags["kubernetes.io/cluster"], "owned"); + assert_eq!(tags[""], "empty"); + assert_eq!( + serde_json::to_value(&tags).unwrap(), + serde_json::json!({"team": "data", "kubernetes.io/cluster": "owned", "": "empty"}) + ); +} + +#[test] +fn byoc_infrastructure_details_missing_and_null_fields_are_none() { + let missing: ByocInfrastructureDetails = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, ByocInfrastructureDetails::default()); + assert_eq!( + serde_json::to_value(&missing).unwrap(), + serde_json::json!({}) + ); + + let null: ByocInfrastructureDetails = serde_json::from_value(serde_json::json!({ + "id": null, "state": null, "accountId": null, "regionId": null, + "cloudProvider": null, "displayName": null, "enablePrivateLink": null, + "enablePrivateLoadBalancer": null, "enablePublicLoadBalancer": null, + "vpcCidrRange": null, "vpcAvailabilityZoneList": null, "isByoVpc": null, + "byoVpcId": null, "byoVpcPrivateSubnetIds": null, "byoVpcPodCidrRangeNames": null, + "byoVpcSharedVpcHostProjectId": null, "gcpPscSubnetId": null + })) + .unwrap(); + assert_eq!(null, ByocInfrastructureDetails::default()); +} + +#[test] +fn byoc_infrastructure_details_enums_preserve_unknown_values() { + let details: ByocInfrastructureDetails = serde_json::from_value(serde_json::json!({ + "state": "infra-hibernating", + "regionId": "mars-north-1", + "cloudProvider": "oci" + })) + .unwrap(); + assert_eq!( + details.state, + Some(ByocInfrastructureDetailsState::Unknown( + "infra-hibernating".to_string() + )) + ); + assert_eq!( + details.region_id, + Some(ByocInfrastructureDetailsRegionid::Unknown( + "mars-north-1".to_string() + )) + ); + assert_eq!( + details.cloud_provider, + Some(ByocInfrastructureDetailsCloudprovider::Unknown( + "oci".to_string() + )) + ); +} + +#[test] +fn byoc_infrastructure_progress_missing_and_null_fields_are_none() { + let missing: ByocInfrastructureProgress = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, ByocInfrastructureProgress::default()); + assert_eq!( + serde_json::to_value(&missing).unwrap(), + serde_json::json!({}) + ); + + let null: ByocInfrastructureProgress = serde_json::from_value(serde_json::json!({ + "id": null, "status": null, "updatedAt": null, "stages": null + })) + .unwrap(); + assert_eq!(null, ByocInfrastructureProgress::default()); +} + +#[test] +fn byoc_infrastructure_progress_stage_missing_and_null_fields_are_none() { + let missing: ByocInfrastructureProgressStage = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, ByocInfrastructureProgressStage::default()); + assert_eq!( + serde_json::to_value(&missing).unwrap(), + serde_json::json!({}) + ); + + let null: ByocInfrastructureProgressStage = serde_json::from_value(serde_json::json!({ + "name": null, "status": null, "updatedAt": null, "message": null, "subStages": null + })) + .unwrap(); + assert_eq!(null, ByocInfrastructureProgressStage::default()); +} + +#[test] +fn byoc_infrastructure_progress_stages_nest_recursively() { + let progress: ByocInfrastructureProgress = serde_json::from_value(serde_json::json!({ + "id": "byoc-1", + "status": "waiting", + "stages": [{ + "name": "network", + "status": "in_progress", + "subStages": [{ + "name": "vpc", + "status": "ready", + "updatedAt": "2026-09-30T10:00:00Z", + "subStages": [{ + "name": "subnet-a", + "status": "not_started", + "message": "queued" + }] + }] + }] + })) + .unwrap(); + assert_eq!( + progress.status, + Some(ByocInfrastructureProgressStatus::Waiting) + ); + let network = &progress.stages.as_ref().unwrap()[0]; + assert_eq!( + network.status, + Some(ByocInfrastructureProgressStageStatus::InProgress) + ); + let vpc = &network.sub_stages.as_ref().unwrap()[0]; + assert_eq!(vpc.name.as_deref(), Some("vpc")); + assert!(vpc.updated_at.is_some()); + let leaf = &vpc.sub_stages.as_ref().unwrap()[0]; + assert_eq!(leaf.name.as_deref(), Some("subnet-a")); + assert_eq!( + leaf.status, + Some(ByocInfrastructureProgressStageStatus::NotStarted) + ); + assert_eq!(leaf.message.as_deref(), Some("queued")); + assert_eq!(leaf.sub_stages, None); + + let reserialized = serde_json::to_value(&progress).unwrap(); + assert_eq!( + reserialized["stages"][0]["subStages"][0]["subStages"][0]["status"], + "not_started" + ); + assert!( + reserialized["stages"][0]["subStages"][0]["subStages"][0] + .get("subStages") + .is_none() + ); +} + +#[test] +fn byoc_infrastructure_progress_status_preserves_unknown_values() { + let status: ByocInfrastructureProgressStatus = + serde_json::from_str(r#""rolling_back""#).unwrap(); + assert_eq!( + status, + ByocInfrastructureProgressStatus::Unknown("rolling_back".to_string()) + ); + assert_eq!(status.to_string(), "rolling_back"); + assert_eq!( + ByocInfrastructureProgressStageStatus::NotStarted.to_string(), + "not_started" + ); +} + +#[test] +fn byoc_infrastructure_validation_missing_and_null_fields_are_none() { + let missing: ByocInfrastructureValidation = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, ByocInfrastructureValidation::default()); + assert_eq!( + serde_json::to_value(&missing).unwrap(), + serde_json::json!({}) + ); + + let null: ByocInfrastructureValidation = serde_json::from_value(serde_json::json!({ + "cloudProvider": null, "allPassed": null, "anyPassed": null, + "supported": null, "checks": null + })) + .unwrap(); + assert_eq!(null, ByocInfrastructureValidation::default()); +} + +#[test] +fn byoc_infrastructure_validation_check_missing_and_null_fields_are_none() { + let missing: ByocInfrastructureValidationCheck = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, ByocInfrastructureValidationCheck::default()); + assert_eq!( + serde_json::to_value(&missing).unwrap(), + serde_json::json!({}) + ); + + let null: ByocInfrastructureValidationCheck = serde_json::from_value(serde_json::json!({ + "name": null, "action": null, "allowed": null, "reason": null, "group": null + })) + .unwrap(); + assert_eq!(null, ByocInfrastructureValidationCheck::default()); +} + +#[test] +fn byoc_availability_zone_suffix_preserves_unknown_values() { + let suffix: ByocAvailabilityZoneSuffix = serde_json::from_str(r#""future""#).unwrap(); + assert_eq!( + suffix, + ByocAvailabilityZoneSuffix::Unknown("future".to_string()) + ); + assert_eq!(serde_json::to_string(&suffix).unwrap(), r#""future""#); +} + +#[test] +fn serialize_byoc_infrastructure_patch_request() { + let req = ByocInfrastructurePatchRequest { + display_name: Some("Renamed".to_string()), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json, serde_json::json!({"displayName": "Renamed"})); +} + +#[test] +fn serialize_byoc_infrastructure_patch_request_full() { + let req = ByocInfrastructurePatchRequest { + display_name: Some("Renamed".to_string()), + enable_private_link: Some(true), + enable_private_load_balancer: Some(true), + enable_public_load_balancer: Some(false), + gcp_psc_subnet_id: Some("psc-subnet".to_string()), + tags: Some(ByocInfrastructureTags::from([( + "team".to_string(), + "data".to_string(), + )])), + }; + assert_eq!( + serde_json::to_value(&req).unwrap(), + serde_json::json!({ + "displayName": "Renamed", + "enablePrivateLink": true, + "enablePrivateLoadBalancer": true, + "enablePublicLoadBalancer": false, + "gcpPscSubnetId": "psc-subnet", + "tags": {"team": "data"} + }) + ); + assert_eq!( + serde_json::to_value(ByocInfrastructurePatchRequest::default()).unwrap(), + serde_json::json!({}) + ); +} + +#[test] +fn byoc_infrastructure_private_endpoint_config_missing_and_null_fields_are_none() { + let missing: ByocInfrastructurePrivateEndpointConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, ByocInfrastructurePrivateEndpointConfig::default()); + assert_eq!( + serde_json::to_value(&missing).unwrap(), + serde_json::json!({}) + ); + + let null: ByocInfrastructurePrivateEndpointConfig = serde_json::from_value(serde_json::json!({ + "endpointName": null, "privateDnsHostname": null + })) + .unwrap(); + assert_eq!(null, ByocInfrastructurePrivateEndpointConfig::default()); +} + +#[test] +fn byoc_infrastructure_tags_response_missing_and_null_fields_are_none() { + let missing: ByocInfrastructureTagsResponse = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, ByocInfrastructureTagsResponse::default()); + assert_eq!( + serde_json::to_value(&missing).unwrap(), + serde_json::json!({}) + ); + + let null: ByocInfrastructureTagsResponse = + serde_json::from_value(serde_json::json!({"tags": null})).unwrap(); + assert_eq!(null, ByocInfrastructureTagsResponse::default()); + + let empty: ByocInfrastructureTagsResponse = + serde_json::from_value(serde_json::json!({"tags": {}})).unwrap(); + assert_eq!(empty.tags, Some(ByocInfrastructureTags::new())); +} + +#[test] +fn byoc_config_regionid_ca_central_1() { + let parsed: ByocConfigRegionid = serde_json::from_str("\"ca-central-1\"").unwrap(); + assert_eq!(parsed, ByocConfigRegionid::Ca_central_1); + assert_eq!(parsed.to_string(), "ca-central-1"); +} + +#[test] +fn byoc_config_state_new_values_round_trip() { + let cases = [ + ("infra-degraded", ByocConfigState::Infra_degraded), + ("infra-terminating", ByocConfigState::Infra_terminating), + ("infra-upgrading", ByocConfigState::Infra_upgrading), + ]; + for (wire, expected) in cases { + let parsed: ByocConfigState = serde_json::from_str(&format!("\"{wire}\"")).unwrap(); + assert_eq!(parsed, expected, "{wire} should not be Unknown"); + assert_eq!(parsed.to_string(), wire); + assert_eq!(serde_json::to_value(&parsed).unwrap(), wire); + } +} + +#[test] +fn byoc_config_account_id_deserializes_and_tolerates_absence() { + let config: ByocConfig = serde_json::from_value(serde_json::json!({ + "accountId": "123456789012", + "state": "infra-ready" + })) + .unwrap(); + assert_eq!(config.account_id.as_deref(), Some("123456789012")); + assert_eq!( + serde_json::to_value(&config).unwrap()["accountId"], + "123456789012" + ); + + for wire in [ + serde_json::json!({}), + serde_json::json!({ "accountId": null }), + ] { + let config: ByocConfig = serde_json::from_value(wire).unwrap(); + assert_eq!(config.account_id, None); + assert!( + serde_json::to_value(&config) + .unwrap() + .get("accountId") + .is_none() + ); + } +} + +#[cfg(not(feature = "deprecated-fields"))] +#[test] +fn byoc_config_deprecated_account_name_is_hidden_by_default() { + let config: ByocConfig = serde_json::from_value(serde_json::json!({ + "accountId": "123456789012", + "accountName": "123456789012" + })) + .unwrap(); + assert!( + serde_json::to_value(&config) + .unwrap() + .get("accountName") + .is_none() + ); +} diff --git a/crates/clickhouse-cloud-api/tests/models/clickpipes.rs b/crates/clickhouse-cloud-api/tests/models/clickpipes.rs new file mode 100644 index 00000000..df78f5f5 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/clickpipes.rs @@ -0,0 +1,1952 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn clickpipe_new_request_fields_preserve_omission_and_wire_names() { + let mut request = ClickPipePostRequest::default(); + assert!( + serde_json::to_value(&request) + .unwrap() + .get("startPaused") + .is_none() + ); + request.start_paused = Some(true); + assert_eq!(serde_json::to_value(&request).unwrap()["startPaused"], true); + + let mut table = ClickPipeDestinationTableDefinition::default(); + assert!(serde_json::to_value(&table).unwrap().get("ttl").is_none()); + table.ttl = "event_time + INTERVAL 30 DAY".to_string(); + let serialized = serde_json::to_value(&table).unwrap(); + assert_eq!(serialized["ttl"], table.ttl); + + let mut settings = ClickPipeMongoDBPipeSettings::default(); + assert!( + serde_json::to_value(&settings) + .unwrap() + .get("initialLoadParallelism") + .is_none() + ); + settings.initial_load_parallelism = Some(4); + let serialized = serde_json::to_value(&settings).unwrap(); + assert_eq!(serialized["initialLoadParallelism"], 4); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + settings + ); +} + +#[test] +fn clickpipe_new_response_fields_tolerate_missing_and_null() { + for value in [serde_json::json!({}), serde_json::json!({"ttl": null})] { + let response: ClickPipeDestinationTableDefinitionResponse = + serde_json::from_value(value).unwrap(); + assert_eq!(response.ttl, None); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({}) + ); + } + for value in [ + serde_json::json!({}), + serde_json::json!({"initialLoadParallelism": null}), + ] { + let response: ClickPipeMongoDBPipeSettingsResponse = serde_json::from_value(value).unwrap(); + assert_eq!(response.initial_load_parallelism, None); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({}) + ); + } + let table: ClickPipeDestinationTableDefinitionResponse = + serde_json::from_value(serde_json::json!({"ttl": "event_time + INTERVAL 30 DAY"})).unwrap(); + assert_eq!(table.ttl.as_deref(), Some("event_time + INTERVAL 30 DAY")); + let settings: ClickPipeMongoDBPipeSettingsResponse = + serde_json::from_value(serde_json::json!({"initialLoadParallelism": 4})).unwrap(); + assert_eq!(settings.initial_load_parallelism, Some(4)); +} + +#[test] +fn deserialize_clickpipe() { + let json = r#"{ + "id": "11111111-1111-1111-1111-111111111111", + "serviceId": "22222222-2222-2222-2222-222222222222", + "name": "my-pipe", + "state": "Running", + "createdAt": "2024-06-01T00:00:00Z", + "updatedAt": "2024-06-01T01:00:00Z" + }"#; + let pipe: ClickPipe = serde_json::from_str(json).unwrap(); + assert_eq!(pipe.name.as_deref(), Some("my-pipe")); + assert_eq!(pipe.state, Some(ClickPipeState::Running)); +} + +#[test] +fn deserialize_clickpipe_settings() { + let json = r#"{ + "streaming_max_insert_wait_ms": 5000, + "object_storage_concurrency": null, + "clickhouse_max_threads": 4, + "kafka_read_committed": true + }"#; + let settings: ClickPipeSettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.streaming_max_insert_wait_ms, Some(5000)); + assert_eq!(settings.object_storage_concurrency, None); + assert_eq!(settings.clickhouse_max_threads, Some(4)); + assert_eq!(settings.kafka_read_committed, Some(true)); +} + +#[test] +fn clickpipe_state_all_variants() { + let states = [ + "Unknown", + "Provisioning", + "Running", + "Stopping", + "Stopped", + "Failed", + "Completed", + "InternalError", + "Setup", + "Snapshot", + "Paused", + "Pausing", + "Modifying", + "Resync", + "Degraded", + ]; + for s in states { + let parsed: ClickPipeState = serde_json::from_str(&format!(r#""{s}""#)).unwrap(); + let serialized = serde_json::to_string(&parsed).unwrap(); + assert_eq!(serialized, format!(r#""{s}""#)); + } +} + +#[test] +fn postgres_table_mapping_table_engines_round_trip_and_match_values() { + // The CLI validates `--table-mapping-json`'s `tableEngine` against + // `VALUES`, so the const must stay equal to the wire values the enum + // actually accepts. + assert_eq!( + ClickPipePostgresPipeTableMappingTableengine::VALUES, + &["MergeTree", "ReplacingMergeTree", "Null"] + ); + for value in ClickPipePostgresPipeTableMappingTableengine::VALUES { + let parsed: ClickPipePostgresPipeTableMappingTableengine = + serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!( + parsed, + ClickPipePostgresPipeTableMappingTableengine::Unknown(_) + ), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + assert_eq!( + serde_json::to_string(&parsed).unwrap(), + format!(r#""{value}""#) + ); + } +} + +#[test] +fn database_table_mapping_table_engines_round_trip_and_match_values() { + macro_rules! assert_values { + ($engine:ty) => {{ + assert_eq!( + <$engine>::VALUES, + &["MergeTree", "ReplacingMergeTree", "Null"] + ); + for value in <$engine>::VALUES { + let parsed: $engine = serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert_eq!(parsed.to_string(), *value); + assert_eq!( + serde_json::to_string(&parsed).unwrap(), + format!(r#""{value}""#) + ); + } + }}; + } + + assert_values!(ClickPipeMySQLPipeTableMappingTableengine); + assert_values!(ClickPipeMongoDBPipeTableMappingTableengine); + assert_values!(ClickPipeBigQueryPipeTableMappingTableengine); +} + +#[test] +fn serialize_clickpipe_state_patch_request() { + let req = ClickPipeStatePatchRequest { + command: Some(ClickPipeStatePatchRequestCommand::Start), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["command"], "start"); + + let stop = ClickPipeStatePatchRequest { + command: Some(ClickPipeStatePatchRequestCommand::Stop), + }; + let json = serde_json::to_value(&stop).unwrap(); + assert_eq!(json["command"], "stop"); +} + +#[test] +fn serialize_clickpipes_cdc_scaling_patch_request() { + let req = ClickPipesCdcScalingPatchRequest { + replica_cpu_millicores: Some(4000), + replica_memory_gb: Some(16.0), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["replicaCpuMillicores"], 4000); + assert_eq!(json["replicaMemoryGb"], 16.0); +} + +#[test] +fn serialize_clickpipe_patch_request() { + let req = ClickPipePatchRequest { + name: Some("renamed-pipe".to_string()), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["name"], "renamed-pipe"); + assert!(json.get("source").is_none()); + assert!(json.get("destination").is_none()); +} + +#[test] +fn serialize_create_reverse_private_endpoint() { + let req = CreateReversePrivateEndpoint { + description: "Test RPE".to_string(), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["description"], "Test RPE"); +} + +#[test] +fn serialize_clickpipe_object_storage_ingestion_controls() { + let source = ClickPipePostObjectStorageSource { + url: "https://bucket.s3.amazonaws.com/events/*.json".to_string(), + skip_initial_load: Some(true), + start_after: Some("events/2026-06-01/".to_string()), + ..Default::default() + }; + let json = serde_json::to_value(&source).unwrap(); + assert_eq!(json["skipInitialLoad"], true); + assert_eq!(json["startAfter"], "events/2026-06-01/"); + + // Omitted from the wire when unset. + let json = serde_json::to_value(ClickPipePostObjectStorageSource::default()).unwrap(); + assert!(json.get("skipInitialLoad").is_none()); + assert!(json.get("startAfter").is_none()); +} + +#[test] +fn serialize_clickpipe_mutate_postgres_source_omits_absent_credentials() { + // IAM_ROLE authentication has no username or password: `iamRole` is the + // whole credential, so the `credentials` object must not reach the wire. + let iam_role = ClickPipeMutatePostgresSource { + authentication: ClickPipeMutatePostgresSourceAuthentication::IAM_ROLE, + credentials: None, + iam_role: Some("arn:aws:iam::123456789012:role/clickpipe".to_string()), + host: "postgres.example".to_string(), + port: 5432, + database: "source-db".to_string(), + ..Default::default() + }; + let json = serde_json::to_value(&iam_role).unwrap(); + assert!( + json.get("credentials").is_none(), + "credentials must be omitted, got {json}" + ); + assert_eq!(json["iamRole"], "arn:aws:iam::123456789012:role/clickpipe"); + assert_eq!(json["authentication"], "IAM_ROLE"); + + // Basic auth still sends the pair verbatim. + let basic = ClickPipeMutatePostgresSource { + authentication: ClickPipeMutatePostgresSourceAuthentication::Basic, + credentials: Some(PLAIN { + username: "user".to_string(), + password: "secret".to_string(), + }), + ..iam_role.clone() + }; + let json = serde_json::to_value(&basic).unwrap(); + assert_eq!(json["credentials"]["username"], "user"); + assert_eq!(json["credentials"]["password"], "secret"); +} + +#[test] +fn clickpipe_ignores_extra_fields() { + let json = r#"{"name":"pipe","state":"Running","newFeatureFlag":true}"#; + let pipe: ClickPipe = serde_json::from_str(json).unwrap(); + assert_eq!(pipe.name.as_deref(), Some("pipe")); + assert_eq!(pipe.state, Some(ClickPipeState::Running)); +} + +#[test] +fn clickpipe_minimal_response() { + let pipe: ClickPipe = serde_json::from_str("{}").unwrap(); + assert_eq!(pipe, ClickPipe::default()); + assert_eq!(pipe.id, None); + assert_eq!(pipe.name, None); + assert_eq!(pipe.state, None); +} + +#[test] +fn schema_discovery_response_tolerates_dropped_and_null_meta() { + let dropped: ClickPipeSchemaDiscoveryResponse = serde_json::from_str("{}").unwrap(); + let nulled: ClickPipeSchemaDiscoveryResponse = + serde_json::from_str(r#"{"fields":null,"meta":null}"#).unwrap(); + + assert_eq!(dropped, ClickPipeSchemaDiscoveryResponse::default()); + assert_eq!(nulled, ClickPipeSchemaDiscoveryResponse::default()); + assert_eq!(serde_json::to_value(nulled).unwrap(), serde_json::json!({})); +} + +#[test] +fn reverse_private_endpoint_dns_targets_and_mappings_tolerate_dropped_and_null_fields() { + // Both nested schemas are response-only, so every field is `Option`. + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ReversePrivateEndpointDnsTarget::default() + ); + assert_eq!( + serde_json::from_str::( + r#"{"id":null,"kind":null,"internalDnsName":null}"# + ) + .unwrap(), + ReversePrivateEndpointDnsTarget::default() + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ReversePrivateEndpointPrivateDnsMapping::default() + ); + assert_eq!( + serde_json::from_str::( + r#"{"privateDnsName":null,"internalDnsName":null}"# + ) + .unwrap(), + ReversePrivateEndpointPrivateDnsMapping::default() + ); + assert_eq!( + serde_json::from_str::( + r#"{"privateDnsName":null,"targetId":null}"# + ) + .unwrap(), + CustomPrivateDnsMappingResponse::default() + ); + + let rpe: ReversePrivateEndpoint = serde_json::from_str( + r#"{ + "customPrivateDnsMappings":[{"privateDnsName":"db.example.com","targetId":"rcfg-1"}], + "dnsTargets":[{"id":"rcfg-1","kind":"RESOURCE_CONFIGURATION","internalDnsName":"a.on.aws"}, + {"id":"rcfg-2","kind":"FUTURE_KIND"}], + "privateDnsMappings":[{"privateDnsName":"my.example.com","internalDnsName":"b.on.aws"}] + }"#, + ) + .unwrap(); + assert_eq!( + rpe.custom_private_dns_mappings.unwrap()[0] + .target_id + .as_deref(), + Some("rcfg-1") + ); + assert_eq!( + rpe.dns_targets, + Some(vec![ + ReversePrivateEndpointDnsTarget { + id: Some("rcfg-1".to_string()), + internal_dns_name: Some("a.on.aws".to_string()), + kind: Some(ReversePrivateEndpointDnsTargetKind::RESOURCE_CONFIGURATION), + }, + ReversePrivateEndpointDnsTarget { + id: Some("rcfg-2".to_string()), + internal_dns_name: None, + kind: Some(ReversePrivateEndpointDnsTargetKind::Unknown( + "FUTURE_KIND".to_string() + )), + }, + ]) + ); + assert_eq!( + rpe.private_dns_mappings, + Some(vec![ReversePrivateEndpointPrivateDnsMapping { + private_dns_name: Some("my.example.com".to_string()), + internal_dns_name: Some("b.on.aws".to_string()), + }]) + ); +} + +#[test] +fn reverse_private_endpoint_omits_absent_fields_when_serialized() { + // Absence stays absent in `--json` output, including inside the nested + // response variant of the shared `customPrivateDnsMapping` schema. + let rpe = ReversePrivateEndpoint { + description: Some("MSK endpoint".to_string()), + custom_private_dns_mappings: Some(vec![CustomPrivateDnsMappingResponse { + private_dns_name: None, + target_id: None, + }]), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&rpe).unwrap(), + serde_json::json!({ + "description": "MSK endpoint", + "customPrivateDnsMappings": [{}], + }) + ); +} + +#[test] +fn reverse_private_endpoint_request_rejects_a_missing_required_field() { + // The request side is strict: without `#[serde(default)]` a payload missing + // `description` or `type` is rejected rather than silently defaulted. + assert!(serde_json::from_str::("{}").is_err()); + let body = CreateReversePrivateEndpoint { + description: "New RPE".to_string(), + r#type: CreateReversePrivateEndpointType::MSK_MULTI_VPC, + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&body).unwrap(), + serde_json::json!({ "description": "New RPE", "type": "MSK_MULTI_VPC" }) + ); +} + +#[test] +fn deserialize_reverse_private_endpoint() { + let json = r#"{ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "description": "MSK endpoint", + "status": "available" + }"#; + let rpe: ReversePrivateEndpoint = serde_json::from_str(json).unwrap(); + assert_eq!(rpe.description.as_deref(), Some("MSK endpoint")); + assert_eq!( + rpe.status, + Some(ReversePrivateEndpointStatus::Other("available".to_string())) + ); +} + +#[test] +fn deserialize_clickpipe_kafka_source() { + let json = r#"{ + "brokers": "broker1:9092,broker2:9092", + "topics": "my-topic", + "groupId": "my-group", + "securityProtocol": "SASL_SSL" + }"#; + let src: ClickPipeKafkaSource = serde_json::from_str(json).unwrap(); + assert_eq!(src.brokers.as_deref(), Some("broker1:9092,broker2:9092")); + assert_eq!(src.topics.as_deref(), Some("my-topic")); +} + +#[test] +fn deserialize_clickpipe_destination() { + let json = r#"{ + "database": "default", + "table": "events", + "managedTable": true, + "columns": [ + {"name": "id", "type": "UInt64"}, + {"name": "ts", "type": "DateTime"} + ] + }"#; + let dest: ClickPipeDestination = serde_json::from_str(json).unwrap(); + assert_eq!(dest.database.as_deref(), Some("default")); + assert_eq!(dest.table.as_deref(), Some("events")); + let columns = dest.columns.expect("columns should populate"); + assert_eq!(columns.len(), 2); + assert_eq!(columns[0].name.as_deref(), Some("id")); +} + +#[test] +fn deserialize_clickpipe_scaling() { + let json = r#"{ + "replicas": 3, + "concurrency": 2 + }"#; + let s: ClickPipeScalingResponse = serde_json::from_str(json).unwrap(); + assert_eq!(s.replicas, Some(3)); + #[cfg(feature = "deprecated-fields")] + assert_eq!(s.concurrency, Some(2)); +} + +// =========================================================================== +// ClickPipe Pub/Sub source (issue #203 drift) +// =========================================================================== + +#[test] +fn deserialize_clickpipe_pubsub_source() { + let json = r#"{ + "topic": "projects/p/topics/t", + "projectId": "my-project", + "authentication": "SERVICE_ACCOUNT", + "format": "JSONEachRow", + "seekType": "latest", + "ackDeadline": 60, + "enableOrdering": true, + "filter": "attribute.foo = \"bar\"" + }"#; + let src: ClickPipePubSubSource = serde_json::from_str(json).unwrap(); + assert_eq!(src.topic.as_deref(), Some("projects/p/topics/t")); + assert_eq!(src.project_id.as_deref(), Some("my-project")); + assert_eq!( + src.authentication, + Some(ClickPipePubSubSourceAuthentication::ServiceAccount) + ); + assert_eq!(src.format, Some(ClickPipePubSubSourceFormat::JSONEachRow)); + assert_eq!(src.seek_type, Some(ClickPipePubSubSourceSeektype::Latest)); + assert_eq!(src.ack_deadline, Some(60)); + assert_eq!(src.enable_ordering, Some(true)); +} + +#[test] +fn deserialize_clickpipe_post_pubsub_source_required_fields() { + let json = r#"{ + "topic": "projects/p/topics/t", + "projectId": "my-project", + "authentication": "SERVICE_ACCOUNT", + "format": "JSONEachRow", + "seekType": "earliest", + "serviceAccountKey": { + "serviceAccountFile": "/path/to/key.json" + } + }"#; + let src: ClickPipePostPubSubSource = serde_json::from_str(json).unwrap(); + let ClickPipePostPubSubSource::ClickPipePostPubSubServiceAccountSource(src) = src else { + panic!("expected service-account source") + }; + assert_eq!(src.topic, "projects/p/topics/t"); + assert_eq!(src.seek_type, ClickPipePostPubSubSourceSeektype::Earliest); + assert_eq!( + src.service_account_key.service_account_file, + "/path/to/key.json" + ); +} + +#[test] +fn serialize_clickpipe_patch_pubsub_source_omits_absent_credentials() { + let empty = serde_json::to_value(ClickPipePatchPubSubSource::default()).unwrap(); + assert_eq!(empty, serde_json::json!({})); + + let ack_deadline_only = serde_json::to_value(ClickPipePatchPubSubSource { + ack_deadline: Some(30), + ..Default::default() + }) + .unwrap(); + assert_eq!(ack_deadline_only, serde_json::json!({"ackDeadline": 30})); +} + +#[test] +fn deserialize_clickpipe_source_with_pubsub() { + let json = r#"{ + "pubsub": { + "topic": "projects/p/topics/t", + "projectId": "p", + "authentication": "SERVICE_ACCOUNT", + "format": "JSONEachRow", + "seekType": "latest" + } + }"#; + let src: ClickPipeSource = serde_json::from_str(json).unwrap(); + let pubsub = src.pubsub.expect("pubsub field should populate"); + assert_eq!(pubsub.topic.as_deref(), Some("projects/p/topics/t")); + assert_eq!( + pubsub.format, + Some(ClickPipePubSubSourceFormat::JSONEachRow) + ); +} + +#[test] +fn click_pipe_schema_discovery_response_round_trip() { + let json = r#"{ + "fields": [ + {"name": "user_id", "type": "Int64", "optional": false}, + {"name": "event", "type": "String", "optional": true} + ] + }"#; + let resp: ClickPipeSchemaDiscoveryResponse = serde_json::from_str(json).unwrap(); + let fields = resp.fields.clone().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[0].optional, Some(false)); + assert_eq!(fields[1].optional, Some(true)); + + let v = serde_json::to_value(&resp).unwrap(); + assert_eq!(v["fields"][0]["name"], "user_id"); + assert_eq!(v["fields"][0]["type"], "Int64"); + assert_eq!(v["fields"][1]["optional"], true); +} + +#[test] +fn click_pipe_schema_discovery_request_kafka_source() { + let req = ClickPipeSchemaDiscoveryRequest { + source: ClickPipeSchemaDiscoverySource { + kafka: Some(ClickPipePostKafkaSource::default()), + kinesis: None, + object_storage: None, + pubsub: None, + }, + }; + let v = serde_json::to_value(&req).unwrap(); + assert!(v["source"]["kafka"].is_object()); + assert!(v["source"].get("kinesis").is_none()); + assert!(v["source"].get("objectStorage").is_none()); + assert!(v["source"].get("pubsub").is_none()); +} + +// A Kafka broker with no authentication has no spec enum value; absence of the +// `authentication` key is the wire representation, so `None` must be omitted +// rather than serialized as a mechanism or as `null`. +#[test] +fn click_pipe_post_kafka_source_omits_absent_authentication() { + let no_auth = ClickPipePostKafkaSource { + brokers: "broker1:9092".to_string(), + topics: "events".to_string(), + authentication: None, + credentials: serde_json::Value::Null, + ..Default::default() + }; + let v = serde_json::to_value(&no_auth).unwrap(); + assert!( + v.get("authentication").is_none(), + "absent authentication must be omitted, got {v}" + ); + assert_eq!(v["brokers"], "broker1:9092"); + + let plain = ClickPipePostKafkaSource { + authentication: Some(ClickPipePostKafkaSourceAuthentication::PLAIN), + credentials: serde_json::json!({"username": "u", "password": "p"}), + ..Default::default() + }; + let v = serde_json::to_value(&plain).unwrap(); + assert_eq!(v["authentication"], "PLAIN"); + assert_eq!(v["credentials"]["username"], "u"); +} + +// `kafka_read_committed` is only supported for Kafka pipes, so a settings PUT +// for any other source must leave the key off the wire entirely — not send +// `false` and not send `null`. +#[test] +fn click_pipe_settings_put_request_omits_absent_kafka_read_committed() { + let non_kafka = ClickPipeSettingsPutRequest { + object_storage_max_file_count: Some(200), + kafka_read_committed: None, + ..Default::default() + }; + let v = serde_json::to_value(&non_kafka).unwrap(); + assert!( + v.get("kafka_read_committed").is_none(), + "absent kafka_read_committed must be omitted, got {v}" + ); + assert_eq!( + v, + serde_json::json!({ "object_storage_max_file_count": 200 }) + ); + + let kafka = ClickPipeSettingsPutRequest { + streaming_max_insert_wait_ms: Some(1000), + kafka_read_committed: Some(true), + ..Default::default() + }; + let v = serde_json::to_value(&kafka).unwrap(); + assert_eq!(v["kafka_read_committed"], true); + assert_eq!(v["streaming_max_insert_wait_ms"], 1000); +} + +#[test] +fn click_pipe_schema_discovery_request_supports_new_sources() { + let object_storage = ClickPipeSchemaDiscoveryRequest { + source: ClickPipeSchemaDiscoverySource { + object_storage: Some(ClickPipePostObjectStorageSource::default()), + ..Default::default() + }, + }; + let pubsub = ClickPipeSchemaDiscoveryRequest { + source: ClickPipeSchemaDiscoverySource { + pubsub: Some(ClickPipePostPubSubSource::default()), + ..Default::default() + }, + }; + + assert!(serde_json::to_value(object_storage).unwrap()["source"]["objectStorage"].is_object()); + assert!(serde_json::to_value(pubsub).unwrap()["source"]["pubsub"].is_object()); +} + +#[test] +fn click_pipe_schema_discovery_request_default_omits_sources() { + let v = serde_json::to_value(ClickPipeSchemaDiscoveryRequest::default()).unwrap(); + assert!(v["source"].get("kafka").is_none()); + assert!(v["source"].get("kinesis").is_none()); +} + +#[test] +fn click_pipe_schema_discovery_field_nullable_optional() { + let json = r#"{"name": "col", "type": "Nullable(String)", "optional": null}"#; + let field: ClickPipeSchemaDiscoveryField = serde_json::from_str(json).unwrap(); + assert_eq!(field.optional, None); + let v = serde_json::to_value(&field).unwrap(); + assert!(v.get("optional").is_none()); +} + +#[test] +fn mysql_source_server_id_optional() { + let json = r#"{"host": "h", "port": 3306, "settings": {"replicationMode": "gtid"}, "tableMappings": [], "serverId": 4242}"#; + let src: ClickPipeMySQLSource = serde_json::from_str(json).unwrap(); + assert_eq!(src.server_id, Some(4242)); + + let json = r#"{"host": "h", "port": 3306, "settings": {"replicationMode": "gtid"}, "tableMappings": []}"#; + let src: ClickPipeMySQLSource = serde_json::from_str(json).unwrap(); + assert_eq!(src.server_id, None); + + let v = serde_json::to_value(ClickPipeMySQLSource { + server_id: Some(99), + ..Default::default() + }) + .unwrap(); + assert_eq!(v["serverId"], 99); + let v = serde_json::to_value(ClickPipeMySQLSource::default()).unwrap(); + assert!(v.get("serverId").is_none()); +} + +#[test] +fn mysql_patch_source_server_id_nullable() { + let json = r#"{"serverId": null}"#; + let src: ClickPipePatchMySQLSource = serde_json::from_str(json).unwrap(); + assert_eq!(src.server_id, None); + + let json = r#"{"serverId": 100}"#; + let src: ClickPipePatchMySQLSource = serde_json::from_str(json).unwrap(); + assert_eq!(src.server_id, Some(100)); +} + +#[test] +fn clickpipe_postgres_table_mapping_partition_by_expr_round_trip() { + // `partitionByExpr` is required and non-nullable, so the request variant + // types it as `String` and always sends it. + let mapping = ClickPipePostgresPipeTableMapping { + partition_by_expr: "toYYYYMM(created_at)".to_string(), + ..Default::default() + }; + let v = serde_json::to_value(&mapping).unwrap(); + assert_eq!(v["partitionByExpr"], "toYYYYMM(created_at)"); + + // The response variant types it as `Option`: present, dropped, and + // explicitly `null` all deserialize. + let mapping: ClickPipePostgresPipeTableMappingResponse = + serde_json::from_str(r#"{"partitionByExpr": "toYYYYMM(created_at)"}"#).unwrap(); + assert_eq!( + mapping.partition_by_expr.as_deref(), + Some("toYYYYMM(created_at)") + ); + let mapping: ClickPipePostgresPipeTableMappingResponse = + serde_json::from_str(r#"{"partitionByExpr": null}"#).unwrap(); + assert_eq!(mapping.partition_by_expr, None); + let mapping: ClickPipePostgresPipeTableMappingResponse = serde_json::from_str("{}").unwrap(); + assert_eq!(mapping.partition_by_expr, None); +} + +#[test] +fn clickpipe_response_tolerates_dropped_and_null_fields() { + // The whole ClickPipe response tree is all-`Option`, so a dropped key and + // an explicit `null` both land as `None` — at the top level and inside the + // nested source/destination/scaling/settings shapes. + let dropped: ClickPipe = serde_json::from_str("{}").unwrap(); + let nulled: ClickPipe = serde_json::from_str( + r#"{"id":null,"serviceId":null,"name":null,"state":null,"createdAt":null, + "updatedAt":null,"scaling":null,"settings":null,"source":null, + "destination":null,"fieldMappings":null}"#, + ) + .unwrap(); + assert_eq!(dropped, ClickPipe::default()); + assert_eq!(nulled, ClickPipe::default()); + // `null` on a list field is the residual case the previous + // `#[serde(default)]` policy still failed on. + assert_eq!(nulled.field_mappings, None); + + let nested: ClickPipe = serde_json::from_str( + r#"{"source":{"postgres":{"host":"pg.example","settings":null, + "tableMappings":null}}, + "destination":{"columns":null,"tableDefinition":{"engine":null, + "sortingKey":null}}, + "scaling":{},"settings":{}}"#, + ) + .unwrap(); + let postgres = nested + .source + .and_then(|source| source.postgres) + .expect("postgres source should populate"); + assert_eq!(postgres.host.as_deref(), Some("pg.example")); + assert_eq!(postgres.settings, None); + assert_eq!(postgres.table_mappings, None); + let destination = nested.destination.expect("destination should populate"); + assert_eq!(destination.columns, None); + assert_eq!( + destination.table_definition, + Some(ClickPipeDestinationTableDefinitionResponse::default()) + ); + assert_eq!(nested.scaling, Some(ClickPipeScalingResponse::default())); + assert_eq!(nested.settings, Some(ClickPipeSettingsResponse::default())); +} + +#[test] +fn clickpipe_response_omits_absent_fields_when_serialized() { + // Absence stays absent in `--json` output, including inside the response + // variants of the shared nested pipe schemas. + let pipe = ClickPipe { + name: Some("my-pipe".to_string()), + scaling: Some(ClickPipeScalingResponse { + replicas: Some(2), + ..Default::default() + }), + field_mappings: Some(vec![ClickPipeFieldMappingResponse { + source_field: Some("id".to_string()), + destination_field: None, + }]), + destination: Some(ClickPipeDestination { + table: Some("events".to_string()), + table_definition: Some(ClickPipeDestinationTableDefinitionResponse { + engine: Some(ClickPipeDestinationTableEngineResponse { + r#type: Some(ClickPipeDestinationTableEngineType::MergeTree), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&pipe).unwrap(), + serde_json::json!({ + "name": "my-pipe", + "scaling": { "replicas": 2 }, + "fieldMappings": [{ "sourceField": "id" }], + "destination": { + "table": "events", + "tableDefinition": { "engine": { "type": "MergeTree" } }, + }, + }) + ); +} + +#[test] +fn shared_clickpipe_nested_types_stay_strict_on_the_request_side() { + // The pipe settings, table mappings, destination shapes, field mappings and + // scaling blocks are sent as well as returned, so each splits: the request + // variant keeps the schema's required fields as `T`, and only the response + // variant is all-`Option`. + let mapping = ClickPipeFieldMapping { + source_field: "id".to_string(), + destination_field: "row_id".to_string(), + }; + assert_eq!( + serde_json::to_value(&mapping).unwrap(), + serde_json::json!({ "sourceField": "id", "destinationField": "row_id" }) + ); + // A request payload missing a required field is rejected, not defaulted. + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::("{}").is_err()); + // The response variants accept the same payload. + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickPipeFieldMappingResponse::default() + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickPipeDestinationColumnResponse::default() + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickPipeScalingResponse::default() + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickPipePostgresPipeTableMappingResponse::default() + ); + // Source-conditional create values stay absent unless the caller supplies + // them, while explicit false remains distinguishable from omission. + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickPipeSettings::default() + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickPipePostSource::default() + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickPipeSettingsPutRequest::default() + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickPipeSettingsResponse::default() + ); +} + +#[test] +fn clickpipe_create_optional_controls_omit_absence_and_preserve_false() { + let source = ClickPipePostSource::default(); + let settings = ClickPipeSettings::default(); + assert_eq!( + serde_json::to_value(&source).unwrap(), + serde_json::json!({}) + ); + assert_eq!( + serde_json::to_value(&settings).unwrap(), + serde_json::json!({}) + ); + + let source = ClickPipePostSource { + validate_samples: Some(false), + ..Default::default() + }; + let settings = ClickPipeSettings { + kafka_read_committed: Some(false), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&source).unwrap(), + serde_json::json!({ "validateSamples": false }) + ); + assert_eq!( + serde_json::to_value(&settings).unwrap(), + serde_json::json!({ "kafka_read_committed": false }) + ); +} + +#[test] +fn clickpipe_patch_remove_table_mapping_partition_by_expr_round_trip() { + let json = r#"{"partitionByExpr": "toDate(ts)"}"#; + let mapping: ClickPipePatchPostgresPipeRemoveTableMapping = serde_json::from_str(json).unwrap(); + assert_eq!(mapping.partition_by_expr.as_deref(), Some("toDate(ts)")); + let v = serde_json::to_value(&mapping).unwrap(); + assert_eq!(v["partitionByExpr"], "toDate(ts)"); + + let v = serde_json::to_value(ClickPipePatchPostgresPipeRemoveTableMapping::default()).unwrap(); + assert!(v.get("partitionByExpr").is_none()); +} + +#[test] +fn reverse_private_endpoint_create_enums_round_trip_and_match_values() { + // The CLI validates `reverse-private-endpoint create --type` and + // `--msk-authentication` against `VALUES`, so each const must stay equal + // to the wire values its enum actually accepts. + assert_eq!( + CreateReversePrivateEndpointType::VALUES, + &[ + "VPC_ENDPOINT_SERVICE", + "VPC_RESOURCE", + "MSK_MULTI_VPC", + "GCP_PSC_SERVICE_ATTACHMENT", + ] + ); + for value in CreateReversePrivateEndpointType::VALUES { + let parsed: CreateReversePrivateEndpointType = + serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!(parsed, CreateReversePrivateEndpointType::Unknown(_)), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + assert_eq!( + serde_json::to_string(&parsed).unwrap(), + format!(r#""{value}""#) + ); + } + + assert_eq!( + CreateReversePrivateEndpointMskauthentication::VALUES, + &["SASL_IAM", "SASL_SCRAM"] + ); + for value in CreateReversePrivateEndpointMskauthentication::VALUES { + let parsed: CreateReversePrivateEndpointMskauthentication = + serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!( + parsed, + CreateReversePrivateEndpointMskauthentication::Unknown(_) + ), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + assert_eq!( + serde_json::to_string(&parsed).unwrap(), + format!(r#""{value}""#) + ); + } +} + +/// The endpoint's own model is a response type: a key the API drops and a key +/// it sends as `null` both have to land as `None`, and neither is emitted back. +#[test] +fn reverse_private_endpoint_tolerates_missing_and_null_fields() { + let sparse: ReversePrivateEndpoint = serde_json::from_str(r#"{"description":"warehouse"}"#) + .expect("a response with one key must deserialize"); + assert_eq!(sparse.description.as_deref(), Some("warehouse")); + assert_eq!(sparse.dns_names, None); + assert_eq!(sparse.status, None); + assert_eq!(sparse.id, None); + + let explicit_nulls: ReversePrivateEndpoint = serde_json::from_str( + r#"{"description":null,"dnsNames":null,"status":null,"id":null,"type":null}"#, + ) + .expect("explicit nulls must deserialize"); + assert_eq!(explicit_nulls, ReversePrivateEndpoint::default()); + assert_eq!( + serde_json::to_string(&explicit_nulls).unwrap(), + "{}", + "absent fields must be omitted, never serialized as null" + ); +} + +#[test] +fn clickpipes_workload_identity_sources_round_trip_without_credentials() { + let bigquery = serde_json::json!({ + "authentication": "SERVICE_ACCOUNT_WORKLOAD_IDENTITY", "projectId": "project", + "snapshotStagingPath": "gs://bucket/staging", "settings": {"replicationMode": "snapshot"}, + "tableMappings": [{"sourceDatasetName": "dataset", "sourceTable": "source", "targetTable": "target"}] + }); + let parsed: ClickPipeMutateBigQuerySource = serde_json::from_value(bigquery.clone()).unwrap(); + assert!(matches!( + parsed, + ClickPipeMutateBigQuerySource::ClickPipePostBigQueryWorkloadIdentitySource(_) + )); + assert_eq!(serde_json::to_value(parsed).unwrap(), bigquery); + let pubsub = serde_json::json!({ + "authentication": "SERVICE_ACCOUNT_WORKLOAD_IDENTITY", "projectId": "project", + "topic": "topic", "format": "JSONEachRow", "seekType": "earliest" + }); + let parsed: ClickPipePostPubSubSource = serde_json::from_value(pubsub.clone()).unwrap(); + assert!(matches!( + parsed, + ClickPipePostPubSubSource::ClickPipePostPubSubWorkloadIdentitySource(_) + )); + assert_eq!(serde_json::to_value(parsed).unwrap(), pubsub); + assert!( + serde_json::from_value::( + serde_json::json!({}) + ) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({})) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({})) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({})) + .is_err() + ); +} + +#[test] +fn bigquery_service_account_allows_omitted_authentication() { + let json = serde_json::json!({ + "snapshotStagingPath": "gs://bucket/staging", "settings": {"replicationMode": "snapshot"}, + "tableMappings": [], "credentials": {"serviceAccountFile": "encoded-key"} + }); + let parsed: ClickPipeMutateBigQuerySource = serde_json::from_value(json.clone()).unwrap(); + assert!(matches!( + parsed, + ClickPipeMutateBigQuerySource::ClickPipePostBigQueryServiceAccountSource(_) + )); + assert_eq!(serde_json::to_value(parsed).unwrap(), json); +} + +#[test] +fn kafka_inline_protobuf_schema_and_schema_registry_are_independent_options() { + let json_source = ClickPipePostKafkaSource { + format: ClickPipePostKafkaSourceFormat::JSONEachRow, + ..Default::default() + }; + assert!( + serde_json::to_value(json_source) + .unwrap() + .get("protobufSchema") + .is_none() + ); + let inline = ClickPipePostKafkaSource { + format: ClickPipePostKafkaSourceFormat::Protobuf, + protobuf_schema: Some("c3ludGF4".into()), + ..Default::default() + }; + let wire = serde_json::to_value(inline).unwrap(); + assert_eq!(wire["protobufSchema"], "c3ludGF4"); + assert!(wire.get("schemaRegistry").is_none()); + let registry = ClickPipePostKafkaSource { + format: ClickPipePostKafkaSourceFormat::Protobuf, + schema_registry: Some(ClickPipeMutateKafkaSchemaRegistry::default()), + ..Default::default() + }; + let wire = serde_json::to_value(registry).unwrap(); + assert!(wire.get("protobufSchema").is_none()); + assert!(wire.get("schemaRegistry").is_some()); +} + +#[test] +fn kafka_patch_source_preserves_partial_and_explicit_list_updates() { + let partial: ClickPipePatchKafkaSource = serde_json::from_value(serde_json::json!({ + "caCertificate": "new-ca" + })) + .unwrap(); + assert!(partial.credentials.is_none()); + assert!(partial.reverse_private_endpoint_ids.is_none()); + assert_eq!( + serde_json::to_value(partial).unwrap(), + serde_json::json!({"caCertificate": "new-ca"}) + ); + + let cleared: ClickPipePatchKafkaSource = serde_json::from_value(serde_json::json!({ + "reversePrivateEndpointIds": [] + })) + .unwrap(); + assert_eq!(cleared.reverse_private_endpoint_ids, Some(vec![])); + assert_eq!( + serde_json::to_value(cleared).unwrap(), + serde_json::json!({"reversePrivateEndpointIds": []}) + ); + + let nulls: ClickPipePatchKafkaSource = serde_json::from_value(serde_json::json!({ + "credentials": null, + "reversePrivateEndpointIds": null + })) + .unwrap(); + assert_eq!(nulls, ClickPipePatchKafkaSource::default()); + assert_eq!(serde_json::to_value(nulls).unwrap(), serde_json::json!({})); +} + +#[test] +fn clickpipe_patch_nested_objects_preserve_omission_and_explicit_empty_values() { + let partial = serde_json::json!({ + "source": { + "postgres": {"host": "postgres.example.com"} + } + }); + let request: ClickPipePatchRequest = serde_json::from_value(partial.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), partial); + + let explicit = serde_json::json!({ + "destination": {"columns": []}, + "source": { + "postgres": { + "tableMappingsToAdd": [], + "tableMappingsToRemove": [] + }, + "validateSamples": false + } + }); + let request: ClickPipePatchRequest = serde_json::from_value(explicit.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), explicit); + + let nulls: ClickPipePatchRequest = serde_json::from_value(serde_json::json!({ + "source": { + "postgres": { + "credentials": null, + "settings": null, + "tableMappingsToAdd": null, + "tableMappingsToRemove": null + }, + "validateSamples": null + } + })) + .unwrap(); + assert_eq!( + serde_json::to_value(nulls).unwrap(), + serde_json::json!({"source": {"postgres": {}}}) + ); +} + +#[test] +fn kinesis_protobuf_schema_round_trips_and_other_formats_omit_it() { + let json_source = ClickPipePostKinesisSource::default(); + let wire = serde_json::to_value(&json_source).unwrap(); + assert!(wire.get("protobufSchema").is_none()); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + json_source + ); + + let protobuf = ClickPipePostKinesisSource { + format: ClickPipePostKinesisSourceFormat::Protobuf, + protobuf_schema: Some("c3ludGF4".into()), + ..Default::default() + }; + let wire = serde_json::to_value(&protobuf).unwrap(); + assert_eq!(wire["format"], "Protobuf"); + assert_eq!(wire["protobufSchema"], "c3ludGF4"); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + protobuf + ); + assert_eq!(protobuf.format.to_string(), "Protobuf"); + + let response: ClickPipeKinesisSource = + serde_json::from_value(serde_json::json!({"format": "Protobuf"})).unwrap(); + assert_eq!( + response.format, + Some(ClickPipeKinesisSourceFormat::Protobuf) + ); + assert_eq!(response.format.unwrap().to_string(), "Protobuf"); + assert!( + matches!(serde_json::from_str::("\"FutureFormat\"").unwrap(), ClickPipeKinesisSourceFormat::Unknown(value) if value == "FutureFormat") + ); +} + +#[test] +fn kinesis_registry_request_is_strict_and_response_is_tolerant() { + let full = serde_json::json!({ + "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events" + }); + let request: ClickPipeKinesisSchemaRegistry = serde_json::from_value(full.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), full); + for field in ["type", "glueRegion", "glueRegistryName"] { + let mut missing = full.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(missing).is_err()); + let mut null = full.clone(); + null[field] = serde_json::Value::Null; + assert!(serde_json::from_value::(null).is_err()); + } + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "type": null, "glueRegion": null, "glueRegistryName": null, "glueRoleArn": null + }), + ] { + let response: ClickPipeKinesisSchemaRegistryResponse = + serde_json::from_value(wire).unwrap(); + assert_eq!(response, ClickPipeKinesisSchemaRegistryResponse::default()); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({}) + ); + } +} + +#[test] +fn kinesis_registry_conversion_reports_nested_missing_fields_and_preserves_unknown_type() { + let response: ClickPipeKinesisSchemaRegistryResponse = + serde_json::from_value(serde_json::json!({ + "type": "future", "glueRegion": "us-east-1", "glueRoleArn": "arn:aws:iam::123:role/Glue" + })) + .unwrap(); + let error = ClickPipeKinesisSchemaRegistry::try_from(response.clone()).unwrap_err(); + assert_eq!(error.fields(), &["glueRegistryName"]); + let mut complete = response; + complete.glue_registry_name = Some("events".into()); + let request = ClickPipeKinesisSchemaRegistry::try_from(complete).unwrap(); + assert_eq!( + serde_json::to_value(request).unwrap(), + serde_json::json!({ + "type": "future", "glueRegion": "us-east-1", "glueRegistryName": "events", + "glueRoleArn": "arn:aws:iam::123:role/Glue" + }) + ); + assert_eq!( + ClickPipeKinesisSchemaRegistryType::Unknown("future".into()).to_string(), + "future" + ); +} + +#[test] +fn kafka_schema_registry_response_dispatches_legacy_confluent_and_glue() { + // Payloads written before Glue support carry no `type`; they stay Confluent. + for wire in [ + serde_json::json!({ + "url": "https://registry.example", "authentication": "PLAIN", + "caCertificate": "CA" + }), + serde_json::json!({ + "type": "confluent", "url": "https://registry.example", + "authentication": "PLAIN", "caCertificate": "CA" + }), + ] { + let registry: ClickPipeKafkaSchemaRegistry = serde_json::from_value(wire.clone()).unwrap(); + let ClickPipeKafkaSchemaRegistry::ClickPipeKafkaConfluentSchemaRegistry(confluent) = + ®istry + else { + panic!("expected Confluent, got {registry:?}"); + }; + assert_eq!(confluent.url.as_deref(), Some("https://registry.example")); + assert_eq!( + confluent.authentication, + Some(ClickPipeKafkaConfluentSchemaRegistryAuthentication::PLAIN) + ); + assert_eq!(confluent.ca_certificate.as_deref(), Some("CA")); + assert_eq!(serde_json::to_value(®istry).unwrap(), wire); + } + + let glue = serde_json::json!({ + "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events", + "glueRoleArn": "arn:aws:iam::123:role/Glue" + }); + let registry: ClickPipeKafkaSchemaRegistry = serde_json::from_value(glue.clone()).unwrap(); + let ClickPipeKafkaSchemaRegistry::ClickPipeKafkaGlueSchemaRegistryResponse(response) = + ®istry + else { + panic!("expected Glue, got {registry:?}"); + }; + assert_eq!( + response.r#type, + Some(ClickPipeKafkaGlueSchemaRegistryType::Glue) + ); + assert_eq!(response.glue_region.as_deref(), Some("us-east-1")); + assert_eq!(response.glue_registry_name.as_deref(), Some("events")); + assert_eq!(serde_json::to_value(®istry).unwrap(), glue); + + // A Glue payload that lost its discriminator, or a future registry type, + // is kept verbatim instead of being misread as Confluent. + for wire in [ + serde_json::json!({"glueRegion": "us-east-1", "glueRegistryName": "events"}), + serde_json::json!({"type": "future", "url": "https://registry.example"}), + ] { + let registry: ClickPipeKafkaSchemaRegistry = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(registry, ClickPipeKafkaSchemaRegistry::Unknown(wire)); + } + + let default = serde_json::to_value(ClickPipeKafkaSchemaRegistry::default()).unwrap(); + assert_eq!(default, serde_json::json!({})); + assert_eq!( + serde_json::from_value::(default).unwrap(), + ClickPipeKafkaSchemaRegistry::default() + ); +} + +#[test] +fn kafka_schema_registry_variants_tolerate_missing_and_null_fields() { + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "type": null, "url": null, "authentication": null, "caCertificate": null + }), + ] { + let confluent: ClickPipeKafkaConfluentSchemaRegistry = + serde_json::from_value(wire).unwrap(); + assert_eq!(confluent, ClickPipeKafkaConfluentSchemaRegistry::default()); + assert_eq!( + serde_json::to_value(confluent).unwrap(), + serde_json::json!({}) + ); + } + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "type": null, "glueRegion": null, "glueRegistryName": null, "glueRoleArn": null + }), + ] { + let glue: ClickPipeKafkaGlueSchemaRegistryResponse = serde_json::from_value(wire).unwrap(); + assert_eq!(glue, ClickPipeKafkaGlueSchemaRegistryResponse::default()); + assert_eq!(serde_json::to_value(glue).unwrap(), serde_json::json!({})); + } +} + +#[test] +fn kafka_source_response_schema_registry_may_be_missing_or_null() { + for wire in [ + serde_json::json!({"brokers": "broker:9092"}), + serde_json::json!({"brokers": "broker:9092", "schemaRegistry": null}), + ] { + let source: ClickPipeKafkaSource = serde_json::from_value(wire).unwrap(); + assert_eq!(source.schema_registry, None); + assert!( + serde_json::to_value(source) + .unwrap() + .get("schemaRegistry") + .is_none() + ); + } + let source: ClickPipeKafkaSource = serde_json::from_value(serde_json::json!({ + "schemaRegistry": {"type": "glue", "glueRegion": "eu-west-1", "glueRegistryName": "r"} + })) + .unwrap(); + assert!(matches!( + source.schema_registry, + Some(ClickPipeKafkaSchemaRegistry::ClickPipeKafkaGlueSchemaRegistryResponse(_)) + )); +} + +#[test] +fn kafka_schema_registry_requests_serialize_each_variant_wire_shape() { + let confluent: ClickPipeMutateKafkaSchemaRegistry = + ClickPipeMutateKafkaConfluentSchemaRegistry { + r#type: None, + url: "https://registry.example".into(), + authentication: ClickPipeMutateKafkaConfluentSchemaRegistryAuthentication::PLAIN, + credentials: ClickPipeKafkaSchemaRegistryCredentials { + username: "user".into(), + password: "secret".into(), + }, + ca_certificate: None, + } + .into(); + assert_eq!( + serde_json::to_value(&confluent).unwrap(), + serde_json::json!({ + "url": "https://registry.example", "authentication": "PLAIN", + "credentials": {"username": "user", "password": "secret"} + }) + ); + let ClickPipeMutateKafkaSchemaRegistry::ClickPipeMutateKafkaConfluentSchemaRegistry( + mut explicit, + ) = confluent + else { + unreachable!() + }; + explicit.r#type = Some(ClickPipeMutateKafkaConfluentSchemaRegistryType::Confluent); + explicit.ca_certificate = Some("CA".into()); + let explicit = ClickPipeMutateKafkaSchemaRegistry::from(explicit); + let wire = serde_json::json!({ + "type": "confluent", "url": "https://registry.example", "authentication": "PLAIN", + "credentials": {"username": "user", "password": "secret"}, "caCertificate": "CA" + }); + assert_eq!(serde_json::to_value(&explicit).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + explicit + ); + + let glue: ClickPipeMutateKafkaSchemaRegistry = ClickPipeKafkaGlueSchemaRegistry { + r#type: ClickPipeKafkaGlueSchemaRegistryType::Glue, + glue_region: "us-east-1".into(), + glue_registry_name: "events".into(), + glue_role_arn: None, + } + .into(); + let wire = serde_json::json!({ + "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events" + }); + assert_eq!(serde_json::to_value(&glue).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + glue + ); + let source = ClickPipePostKafkaSource { + schema_registry: Some(glue), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(source).unwrap()["schemaRegistry"]["type"], + "glue" + ); +} + +#[test] +fn kafka_schema_registry_request_variants_are_strict() { + let confluent = serde_json::json!({ + "url": "https://registry.example", "authentication": "PLAIN", + "credentials": {"username": "user", "password": "secret"} + }); + serde_json::from_value::(confluent.clone()) + .unwrap(); + for field in ["url", "authentication", "credentials"] { + let mut missing = confluent.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(missing).is_err() + ); + } + let glue = serde_json::json!({ + "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events" + }); + for field in ["type", "glueRegion", "glueRegistryName"] { + let mut missing = glue.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(missing).is_err()); + let mut null = glue.clone(); + null[field] = serde_json::Value::Null; + assert!(serde_json::from_value::(null).is_err()); + } +} + +#[test] +fn kafka_glue_registry_conversion_reports_missing_fields() { + let response: ClickPipeKafkaGlueSchemaRegistryResponse = + serde_json::from_value(serde_json::json!({ + "glueRegion": "us-east-1", "glueRoleArn": "arn:aws:iam::123:role/Glue" + })) + .unwrap(); + let error = ClickPipeKafkaGlueSchemaRegistry::try_from(response.clone()).unwrap_err(); + assert_eq!(error.fields(), &["type", "glueRegistryName"]); + let mut complete = response; + complete.r#type = Some(ClickPipeKafkaGlueSchemaRegistryType::Glue); + complete.glue_registry_name = Some("events".into()); + let request = ClickPipeKafkaGlueSchemaRegistry::try_from(complete).unwrap(); + assert_eq!( + serde_json::to_value(request).unwrap(), + serde_json::json!({ + "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events", + "glueRoleArn": "arn:aws:iam::123:role/Glue" + }) + ); + assert_eq!( + ClickPipeKafkaGlueSchemaRegistryType::Glue.to_string(), + "glue" + ); + assert_eq!( + ClickPipeKafkaConfluentSchemaRegistryType::Confluent.to_string(), + "confluent" + ); +} + +#[test] +fn kafka_tombstone_mode_is_lossless_and_optional() { + let request = ClickPipePostKafkaSource::default(); + assert!( + serde_json::to_value(request) + .unwrap() + .get("tombstoneMode") + .is_none() + ); + let response: ClickPipeKafkaSource = serde_json::from_value(serde_json::json!({ + "tombstoneMode": "future" + })) + .unwrap(); + assert!( + matches!(response.tombstone_mode, Some(ClickPipeKafkaSourceTombstonemode::Unknown(ref value)) if value == "future") + ); + let request = ClickPipePostKafkaSource { + tombstone_mode: Some(ClickPipePostKafkaSourceTombstonemode::Delete), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(request).unwrap()["tombstoneMode"], + "delete" + ); + assert_eq!( + ClickPipePostKafkaSourceTombstonemode::Delete.to_string(), + "delete" + ); + for wire in [ + serde_json::json!({}), + serde_json::json!({"tombstoneMode": null}), + ] { + let response: ClickPipeKafkaSource = serde_json::from_value(wire).unwrap(); + assert_eq!(response.tombstone_mode, None); + assert!( + serde_json::to_value(response) + .unwrap() + .get("tombstoneMode") + .is_none() + ); + } +} + +#[test] +fn kinesis_sources_omit_missing_or_null_registry() { + let request = ClickPipePostKinesisSource::default(); + assert!( + serde_json::to_value(request) + .unwrap() + .get("schemaRegistry") + .is_none() + ); + for wire in [ + serde_json::json!({}), + serde_json::json!({"schemaRegistry": null}), + ] { + let response: ClickPipeKinesisSource = serde_json::from_value(wire).unwrap(); + assert_eq!(response.schema_registry, None); + assert!( + serde_json::to_value(response) + .unwrap() + .get("schemaRegistry") + .is_none() + ); + } +} + +#[test] +fn kafka_tombstone_mode_soft_delete_round_trips() { + let parsed: ClickPipeKafkaSourceTombstonemode = + serde_json::from_str(r#""soft_delete""#).unwrap(); + assert_eq!(parsed, ClickPipeKafkaSourceTombstonemode::Soft_delete); + assert_eq!(parsed.to_string(), "soft_delete"); + assert_eq!(serde_json::to_value(&parsed).unwrap(), "soft_delete"); + + let parsed: ClickPipePostKafkaSourceTombstonemode = + serde_json::from_str(r#""soft_delete""#).unwrap(); + assert_eq!(parsed, ClickPipePostKafkaSourceTombstonemode::Soft_delete); + assert_eq!(parsed.to_string(), "soft_delete"); + let request = ClickPipePostKafkaSource { + tombstone_mode: Some(ClickPipePostKafkaSourceTombstonemode::Soft_delete), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(request).unwrap()["tombstoneMode"], + "soft_delete" + ); +} + +#[test] +fn database_clickpipe_sources_accept_workload_identity_and_cloud_sql_mysql() { + macro_rules! assert_round_trip { + ($ty:ty, $variant:expr, $wire:literal) => {{ + let parsed: $ty = serde_json::from_str(concat!('"', $wire, '"')).unwrap(); + assert_eq!(parsed, $variant); + assert_eq!(parsed.to_string(), $wire); + assert_eq!( + serde_json::to_string(&parsed).unwrap(), + concat!('"', $wire, '"') + ); + }}; + } + + assert_round_trip!( + ClickPipeMutateMySQLSourceAuthentication, + ClickPipeMutateMySQLSourceAuthentication::ServiceAccountWorkloadIdentity, + "SERVICE_ACCOUNT_WORKLOAD_IDENTITY" + ); + assert_round_trip!( + ClickPipeMySQLSourceAuthentication, + ClickPipeMySQLSourceAuthentication::ServiceAccountWorkloadIdentity, + "SERVICE_ACCOUNT_WORKLOAD_IDENTITY" + ); + assert_round_trip!( + ClickPipePatchMySQLSourceAuthentication, + ClickPipePatchMySQLSourceAuthentication::ServiceAccountWorkloadIdentity, + "SERVICE_ACCOUNT_WORKLOAD_IDENTITY" + ); + assert_round_trip!( + ClickPipeMutatePostgresSourceAuthentication, + ClickPipeMutatePostgresSourceAuthentication::ServiceAccountWorkloadIdentity, + "SERVICE_ACCOUNT_WORKLOAD_IDENTITY" + ); + assert_round_trip!( + ClickPipePostgresSourceAuthentication, + ClickPipePostgresSourceAuthentication::ServiceAccountWorkloadIdentity, + "SERVICE_ACCOUNT_WORKLOAD_IDENTITY" + ); + assert_round_trip!( + ClickPipeMutateMySQLSourceType, + ClickPipeMutateMySQLSourceType::Cloudsqlmysql, + "cloudsqlmysql" + ); + assert_round_trip!( + ClickPipeMySQLSourceType, + ClickPipeMySQLSourceType::Cloudsqlmysql, + "cloudsqlmysql" + ); + assert!( + ClickPipeMutateMySQLSourceAuthentication::VALUES + .contains(&"SERVICE_ACCOUNT_WORKLOAD_IDENTITY") + ); + assert!(ClickPipeMutateMySQLSourceType::VALUES.contains(&"cloudsqlmysql")); +} + +#[test] +fn mongodb_structured_ingestion_request_is_strict_and_omits_unset_fields() { + let minimal = ClickPipeMongoDBPipeTableMapping { + source_database_name: "mydb".to_string(), + source_collection: "users".to_string(), + target_table: "mydb_users".to_string(), + structured_ingestion: Some(ClickPipeMongoDBStructuredIngestion { + enabled: true, + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + serde_json::json!({ + "sourceDatabaseName": "mydb", + "sourceCollection": "users", + "targetTable": "mydb_users", + "structuredIngestion": { "enabled": true } + }) + ); + + let maximal: ClickPipeMongoDBStructuredIngestion = serde_json::from_value(serde_json::json!({ + "enabled": true, + "dropUnexpectedValues": false, + "columns": [{ "name": "amount", "type": "Float64" }], + "excludedColumns": ["internal_notes"], + "sortingKeys": ["_id", "created_at"], + "partitionByExpr": "toYYYYMM(created_at)" + })) + .unwrap(); + assert_eq!( + maximal.columns, + Some(vec![ClickPipeMongoDBPipeColumn { + name: "amount".to_string(), + r#type: "Float64".to_string(), + }]) + ); + assert_eq!( + maximal.partition_by_expr.as_deref(), + Some("toYYYYMM(created_at)") + ); + + // `enabled` and both column fields are required in request position. + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::(r#"{"name":"amount"}"#).is_err()); +} + +#[test] +fn mongodb_structured_ingestion_response_tolerates_absent_and_null_fields() { + let mapping: ClickPipeMongoDBPipeTableMappingResponse = serde_json::from_str( + r#"{"sourceDatabaseName":"mydb","structuredIngestion":{"enabled":null,"columns":[{"name":null}],"sortingKeys":null}}"#, + ) + .unwrap(); + let ingestion = mapping.structured_ingestion.unwrap(); + assert_eq!(ingestion.enabled, None); + assert_eq!( + ingestion.columns, + Some(vec![ClickPipeMongoDBPipeColumnResponse::default()]) + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickPipeMongoDBStructuredIngestionResponse::default() + ); + assert_eq!( + serde_json::to_value(ClickPipeMongoDBStructuredIngestionResponse::default()).unwrap(), + serde_json::json!({}) + ); +} + +#[test] +fn mongodb_table_config_request_is_strict_and_round_trips() { + let mapping = ClickPipeMongoDBPipeTableMapping { + source_database_name: "mydb".to_string(), + source_collection: "users".to_string(), + target_table: "mydb_users".to_string(), + mongo_config: Some(ClickPipeMongoDBTableConfig { + persist_deleted_documents: true, + }), + ..Default::default() + }; + let wire = serde_json::json!({ + "sourceDatabaseName": "mydb", + "sourceCollection": "users", + "targetTable": "mydb_users", + "mongoConfig": { "persistDeletedDocuments": true } + }); + assert_eq!(serde_json::to_value(&mapping).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + mapping + ); + + // `persistDeletedDocuments` is required in request position, so an + // explicit `false` is sent and an empty config is rejected. + assert_eq!( + serde_json::to_value(ClickPipeMongoDBTableConfig::default()).unwrap(), + serde_json::json!({ "persistDeletedDocuments": false }) + ); + assert!(serde_json::from_str::("{}").is_err()); + + // The mapping itself omits an unset `mongoConfig`. + let unset = ClickPipeMongoDBPipeTableMapping { + mongo_config: None, + ..mapping + }; + assert!( + serde_json::to_value(&unset) + .unwrap() + .get("mongoConfig") + .is_none() + ); +} + +#[test] +fn mongodb_table_config_response_tolerates_absent_and_null_fields() { + for raw in [ + r#"{"sourceDatabaseName":"mydb"}"#, + r#"{"sourceDatabaseName":"mydb","mongoConfig":null}"#, + ] { + let mapping: ClickPipeMongoDBPipeTableMappingResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(mapping.mongo_config, None); + } + for raw in [ + r#"{"mongoConfig":{}}"#, + r#"{"mongoConfig":{"persistDeletedDocuments":null}}"#, + ] { + let mapping: ClickPipeMongoDBPipeTableMappingResponse = serde_json::from_str(raw).unwrap(); + assert_eq!( + mapping.mongo_config, + Some(ClickPipeMongoDBTableConfigResponse::default()) + ); + } + let mapping: ClickPipeMongoDBPipeTableMappingResponse = + serde_json::from_str(r#"{"mongoConfig":{"persistDeletedDocuments":false}}"#).unwrap(); + assert_eq!( + mapping.mongo_config.unwrap().persist_deleted_documents, + Some(false) + ); + assert_eq!( + serde_json::to_value(ClickPipeMongoDBTableConfigResponse::default()).unwrap(), + serde_json::json!({}) + ); +} + +#[test] +fn bigquery_cdc_fields_round_trip_and_write_back() { + let settings = serde_json::json!({ + "replicationMode": "cdc", "replicationMethod": "query_based", + "syncIntervalSeconds": 60, "queryCdcPullSyncParallelism": 4, + "syncDelaySeconds": 0, "pullWindowSizeSeconds": 86400 + }); + let response: ClickPipeBigQueryPipeSettingsResponse = + serde_json::from_value(settings.clone()).unwrap(); + let request = ClickPipeBigQueryPipeSettings::try_from(response).unwrap(); + assert_eq!(request.replication_method.as_deref(), Some("query_based")); + assert_eq!(request.sync_interval_seconds, Some(60)); + assert_eq!(request.query_cdc_pull_sync_parallelism, Some(4)); + assert_eq!(request.sync_delay_seconds, Some(0)); + assert_eq!(request.pull_window_size_seconds, Some(86400)); + assert_eq!(serde_json::to_value(&request).unwrap(), settings); + assert_eq!( + serde_json::from_value::(settings).unwrap(), + request + ); + + let mapping = serde_json::json!({ + "sourceDatasetName": "sales", "sourceTable": "orders", "targetTable": "orders_raw", + "queryCdcWatermarkColumn": "updated_at", "eventsFunction": "future_function" + }); + let response: ClickPipeBigQueryPipeTableMappingResponse = + serde_json::from_value(mapping.clone()).unwrap(); + let request = ClickPipeBigQueryPipeTableMapping::try_from(response).unwrap(); + assert_eq!( + request.query_cdc_watermark_column.as_deref(), + Some("updated_at") + ); + assert_eq!(request.events_function.as_deref(), Some("future_function")); + assert_eq!(serde_json::to_value(request).unwrap(), mapping); +} + +#[test] +fn bigquery_cdc_response_fields_tolerate_absence_and_null() { + for value in [ + serde_json::json!({}), + serde_json::json!({ + "replicationMode": null, "replicationMethod": null, "syncIntervalSeconds": null, + "queryCdcPullSyncParallelism": null, "syncDelaySeconds": null, "pullWindowSizeSeconds": null + }), + ] { + let response: ClickPipeBigQueryPipeSettingsResponse = + serde_json::from_value(value).unwrap(); + assert_eq!(response, ClickPipeBigQueryPipeSettingsResponse::default()); + assert_eq!( + serde_json::to_value(&response).unwrap(), + serde_json::json!({}) + ); + assert_eq!( + ClickPipeBigQueryPipeSettings::try_from(response) + .unwrap_err() + .fields(), + &["replicationMode"] + ); + } + for value in [ + serde_json::json!({}), + serde_json::json!({ + "eventsFunction": null, "queryCdcWatermarkColumn": null + }), + ] { + let response: ClickPipeBigQueryPipeTableMappingResponse = + serde_json::from_value(value).unwrap(); + assert_eq!( + response, + ClickPipeBigQueryPipeTableMappingResponse::default() + ); + assert_eq!( + serde_json::to_value(&response).unwrap(), + serde_json::json!({}) + ); + assert_eq!( + ClickPipeBigQueryPipeTableMapping::try_from(response) + .unwrap_err() + .fields(), + &["sourceDatasetName", "sourceTable", "targetTable"] + ); + } + assert!( + serde_json::from_value::(serde_json::json!({})).is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({})).is_err() + ); +} + +#[test] +fn bigquery_sync_cron_round_trips_writes_back_and_tolerates_absence() { + let settings = serde_json::json!({ + "replicationMode": "cdc", "replicationMethod": "query_based", + "syncCron": "CRON_TZ=Europe/London 0 * * * *" + }); + let response: ClickPipeBigQueryPipeSettingsResponse = + serde_json::from_value(settings.clone()).unwrap(); + assert_eq!( + response.sync_cron.as_deref(), + Some("CRON_TZ=Europe/London 0 * * * *") + ); + let request = ClickPipeBigQueryPipeSettings::try_from(response).unwrap(); + assert_eq!( + request.sync_cron.as_deref(), + Some("CRON_TZ=Europe/London 0 * * * *") + ); + assert_eq!(request.sync_interval_seconds, None); + assert_eq!(serde_json::to_value(&request).unwrap(), settings); + + let unset = ClickPipeBigQueryPipeSettings { + replication_mode: ClickPipeBigQueryPipeSettingsReplicationmode::Snapshot, + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&unset).unwrap(), + serde_json::json!({"replicationMode": "snapshot"}) + ); + + for value in [ + serde_json::json!({"replicationMode": "cdc"}), + serde_json::json!({"replicationMode": "cdc", "syncCron": null}), + ] { + let response: ClickPipeBigQueryPipeSettingsResponse = + serde_json::from_value(value).unwrap(); + assert_eq!(response.sync_cron, None); + let request = ClickPipeBigQueryPipeSettings::try_from(response).unwrap(); + assert_eq!(request.sync_cron, None); + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::json!({"replicationMode": "cdc"}) + ); + } +} + +#[test] +fn bigquery_replication_modes_cover_cdc_and_preserve_future_values() { + for (wire, expected) in [ + ( + "snapshot", + ClickPipeBigQueryPipeSettingsReplicationmode::Snapshot, + ), + ("cdc", ClickPipeBigQueryPipeSettingsReplicationmode::Cdc), + ( + "cdc_only", + ClickPipeBigQueryPipeSettingsReplicationmode::CdcOnly, + ), + ( + "future", + ClickPipeBigQueryPipeSettingsReplicationmode::Unknown("future".into()), + ), + ] { + let parsed: ClickPipeBigQueryPipeSettingsReplicationmode = + serde_json::from_value(serde_json::json!(wire)).unwrap(); + assert_eq!(parsed, expected); + assert_eq!(parsed.to_string(), wire); + assert_eq!( + serde_json::to_value(parsed).unwrap(), + serde_json::json!(wire) + ); + } + assert_eq!( + ClickPipeBigQueryPipeSettingsReplicationmode::VALUES, + &["snapshot", "cdc", "cdc_only"] + ); +} diff --git a/crates/clickhouse-cloud-api/tests/models/clickstack.rs b/crates/clickhouse-cloud-api/tests/models/clickstack.rs new file mode 100644 index 00000000..6678498f --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/clickstack.rs @@ -0,0 +1,4803 @@ +use clickhouse_cloud_api::models::*; + +use crate::assert_unknown_variant_round_trips; + +#[test] +fn deserialize_clickstack_dashboard_response() { + let json = r#"{ + "id": "dash-123", + "name": "My Dashboard", + "tiles": [], + "filters": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-02T00:00:00Z" + }"#; + let dash: ClickStackDashboardResponse = serde_json::from_str(json).unwrap(); + assert_eq!(dash.name.as_deref(), Some("My Dashboard")); +} + +#[test] +fn clickstack_dashboard_minimal_response() { + let d: ClickStackDashboardResponse = serde_json::from_str("{}").unwrap(); + assert_eq!(d.id, None); + assert_eq!(d.name, None); +} + +// =========================================================================== +// ClickStack dashboard containers, on-click (issue #203 drift) +// =========================================================================== + +#[test] +fn deserialize_clickstack_dashboard_with_containers() { + let json = r#"{ + "id": "dash-1", + "name": "Overview", + "tiles": [ + { + "id": "tile-1", + "name": "T1", + "x": 0, "y": 0, "w": 4, "h": 4, + "containerId": "c-1", + "tabId": "t-1" + } + ], + "filters": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-02T00:00:00Z", + "containers": [ + { + "id": "c-1", + "title": "Container A", + "collapsed": false, + "tabs": [{"id": "t-1", "title": "Tab 1"}] + } + ] + }"#; + let dash: ClickStackDashboardResponse = serde_json::from_str(json).unwrap(); + let containers = dash.containers.expect("containers should populate"); + assert_eq!(containers.len(), 1); + assert_eq!(containers[0].id.as_deref(), Some("c-1")); + assert_eq!(containers[0].collapsed, Some(false)); + let tabs = containers[0].tabs.as_ref().expect("tabs populated"); + assert_eq!(tabs[0].title.as_deref(), Some("Tab 1")); + let tiles = dash.tiles.expect("tiles should populate"); + assert_eq!(tiles[0].container_id.as_deref(), Some("c-1")); + assert_eq!(tiles[0].tab_id.as_deref(), Some("t-1")); +} + +#[test] +fn deserialize_clickstack_on_click_search_variant() { + // ClickStackOnClick is an untagged enum; the Search variant comes first + // so a "search"-typed payload deserializes through it cleanly. + let json = r#"{ + "type": "search", + "target": {"mode": "id", "id": "search-1"} + }"#; + let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); + match on_click { + ClickStackOnClick::ClickStackOnClickSearch(s) => { + assert_eq!(s.r#type, ClickStackOnClickSearchType::Search); + } + other => panic!("expected search variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_on_click_dashboard_struct() { + // Regression: Search and Dashboard are structurally identical (both require + // `target` + `type`), so before manual discriminator dispatch a + // `type: "dashboard"` payload misdispatched to the Search variant. The union + // now routes on the `type` discriminator. + let json = r#"{ + "type": "dashboard", + "target": {"mode": "template", "template": "{{x}}"}, + "whereLanguage": "sql", + "whereTemplate": "x = {{y}}" + }"#; + let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); + let dash = match on_click { + ClickStackOnClick::ClickStackOnClickDashboard(dash) => dash, + other => panic!("expected dashboard variant, got {other}"), + }; + assert_eq!(dash.r#type, ClickStackOnClickDashboardType::Dashboard); + assert_eq!(dash.where_template.as_deref(), Some("x = {{y}}")); + match dash.target { + ClickStackOnClickTarget::ClickStackOnClickTargetTemplateVariant(t) => { + assert_eq!(t.template, "{{x}}"); + } + other => panic!("expected template target, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_on_click_target_id_variant() { + let json = r#"{"mode": "id", "id": "abc"}"#; + let target: ClickStackOnClickTarget = serde_json::from_str(json).unwrap(); + match target { + ClickStackOnClickTarget::ClickStackOnClickTargetIdVariant(v) => { + assert_eq!(v.id, "abc"); + assert_eq!(v.mode, ClickStackOnClickTargetIdVariantMode::Id); + } + other => panic!("expected id variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_on_click_target_template_variant() { + let json = r#"{"mode": "template", "template": "{{q}}"}"#; + let target: ClickStackOnClickTarget = serde_json::from_str(json).unwrap(); + match target { + ClickStackOnClickTarget::ClickStackOnClickTargetTemplateVariant(v) => { + assert_eq!(v.template, "{{q}}"); + assert_eq!(v.mode, ClickStackOnClickTargetTemplateVariantMode::Template); + } + other => panic!("expected template variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_on_click_target_unknown_shape_round_trips() { + // A payload matching neither the id nor template variant must land in the + // lossless Unknown catch-all and re-serialize to the same JSON object rather + // than erroring on deserialize. + let json = r#"{ + "mode": "future_mode", + "foo": 1 + }"#; + assert_unknown_variant_round_trips(json, |t: &ClickStackOnClickTarget| { + matches!(t, ClickStackOnClickTarget::Unknown(_)) + }); +} + +#[test] +fn deserialize_clickstack_on_click_external_round_trip() { + let json = r#"{"type": "external", "urlTemplate": "https://example.com/{{ServiceName}}"}"#; + let ext: ClickStackOnClickExternal = serde_json::from_str(json).unwrap(); + assert_eq!(ext.r#type, ClickStackOnClickExternalType::External); + assert_eq!(ext.url_template, "https://example.com/{{ServiceName}}"); + let v = serde_json::to_value(&ext).unwrap(); + assert_eq!(v["type"], "external"); + assert_eq!(v["urlTemplate"], "https://example.com/{{ServiceName}}"); +} + +#[test] +fn deserialize_clickstack_on_click_dispatches_external() { + // An "external" on-click payload has no `target`, so it cannot match the + // Search or Dashboard variants (both require `target`) and dispatches + // through the untagged union to the External variant. + let json = r#"{"type": "external", "urlTemplate": "https://example.com/{{value}}"}"#; + let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); + match &on_click { + ClickStackOnClick::ClickStackOnClickExternal(ext) => { + assert_eq!(ext.r#type, ClickStackOnClickExternalType::External); + assert_eq!(ext.url_template, "https://example.com/{{value}}"); + } + other => panic!("expected external variant, got {other}"), + } + // Round-trips back to the same wire shape through the union. + let v = serde_json::to_value(&on_click).unwrap(); + assert_eq!(v["type"], "external"); + assert_eq!(v["urlTemplate"], "https://example.com/{{value}}"); +} + +#[test] +fn deserialize_clickstack_on_click_dispatches_search() { + // Regression: adding the External variant must not steal the Search shape. + let json = r#"{ + "type": "search", + "target": {"mode": "id", "id": "search-1"} + }"#; + let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); + match on_click { + ClickStackOnClick::ClickStackOnClickSearch(s) => { + assert_eq!(s.r#type, ClickStackOnClickSearchType::Search); + } + other => panic!("expected search variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_on_click_dashboard_still_parses() { + // Regression: a `type: "dashboard"` payload dispatches to the Dashboard + // variant through the union rather than being greedily absorbed by the + // structurally identical Search variant. + let json = r#"{ + "type": "dashboard", + "target": {"mode": "template", "template": "{{x}}"} + }"#; + let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); + match on_click { + ClickStackOnClick::ClickStackOnClickDashboard(dash) => { + assert_eq!(dash.r#type, ClickStackOnClickDashboardType::Dashboard); + } + other => panic!("expected dashboard variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_on_click_unknown_type_round_trip() { + // An unrecognized `type` discriminator falls back to the Unknown variant, + // which stores the raw JSON so it round-trips faithfully. + let json = r#"{"type":"popover","payload":{"nested":[1,2,3]}}"#; + let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); + match &on_click { + ClickStackOnClick::Unknown(v) => { + assert_eq!(v["type"], "popover"); + assert_eq!(v["payload"]["nested"][2], 3); + } + other => panic!("expected unknown variant, got {other}"), + } + let expected: serde_json::Value = serde_json::from_str(json).unwrap(); + assert_eq!(serde_json::to_value(&on_click).unwrap(), expected); +} + +#[test] +fn clickstack_alert_channel_known_variants_deserialize() { + let email_json = r#"{"emailRecipients": ["a@b.com"], "type": "email"}"#; + let email: ClickStackAlertChannel = serde_json::from_str(email_json).unwrap(); + match email { + ClickStackAlertChannel::ClickStackAlertChannelEmail(v) => { + assert_eq!(v.email_recipients, vec!["a@b.com".to_string()]); + assert_eq!(v.r#type, ClickStackAlertChannelEmailType::Email); + } + other => panic!("expected email variant, got {other}"), + } + + let webhook_json = r#"{"webhookId": "wh-1", "type": "webhook"}"#; + let webhook: ClickStackAlertChannel = serde_json::from_str(webhook_json).unwrap(); + match webhook { + ClickStackAlertChannel::ClickStackAlertChannelWebhook(v) => { + assert_eq!(v.webhook_id, "wh-1"); + assert_eq!(v.r#type, ClickStackAlertChannelWebhookType::Webhook); + } + other => panic!("expected webhook variant, got {other}"), + } +} + +#[test] +fn clickstack_alert_channel_response_known_type_sparse_payload_is_tolerant() { + // A recognized `type` dispatches hard to its variant, and the response + // variant's all-`Option` fields are tolerant: a server that drops + // `emailRecipients` surfaces `None` rather than failing the response. + let json = r#"{"type":"email"}"#; + let channel: ClickStackAlertChannelResponse = serde_json::from_str(json).unwrap(); + match channel { + ClickStackAlertChannelResponse::ClickStackAlertChannelEmail(v) => { + assert_eq!(v.r#type, Some(ClickStackAlertChannelEmailType::Email)); + assert_eq!(v.email_recipients, None); + } + other => panic!("expected email variant, got {other}"), + } + // The request variant stays strict: the same sparse payload is not a valid + // channel to *send*. + assert!(serde_json::from_str::(r#"{"type":"email"}"#).is_err()); +} + +#[test] +fn clickstack_alert_channel_missing_type_key_is_unknown() { + // This union has no arm for an absent `type` key, in deliberate contrast to + // the chart-config unions where absence means the Builder variant, so a + // payload without the discriminator lands in the lossless Unknown catch-all. + let json = r#"{"webhookId":"wh-1"}"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChannel| { + matches!(c, ClickStackAlertChannel::Unknown(_)) + }); +} + +#[test] +fn clickstack_alert_channel_unknown_shape_round_trips() { + // An unrecognized `type` value must land in the lossless Unknown catch-all + // and re-serialize to the same JSON object rather than erroring on + // deserialize. + let json = r#"{ + "type": "future_channel", + "foo": 1 + }"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChannel| { + matches!(c, ClickStackAlertChannel::Unknown(_)) + }); +} + +#[test] +fn clickstack_alert_channel_changed_field_shape_is_unknown() { + // A recognized `type` whose payload no longer fits the variant — here + // `emailRecipients` as a string instead of an array — must not fail the + // response. `list_alerts` returns a Vec, so one such element would otherwise + // take down the whole call; instead the element lands in Unknown intact. + let json = r#"{"type":"email","emailRecipients":"a@b.c"}"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChannel| { + matches!(c, ClickStackAlertChannel::Unknown(_)) + }); +} + +#[test] +fn clickstack_alert_channel_default_round_trips_to_the_same_variant() { + // Both alert-channel variants declare the same `enum: ["webhook", "email"]` + // for their discriminating `type`, so the email variant's default must name + // `email`: `channel` is defaulted on the alert response, and a default that + // named `webhook` would come back from its own union as the webhook variant + // and could be PUT back as a webhook channel with no `webhookId`. + let default = ClickStackAlertChannel::default(); + assert!(matches!( + default, + ClickStackAlertChannel::ClickStackAlertChannelEmail(_) + )); + let json = serde_json::to_string(&default).unwrap(); + assert_eq!(json, r#"{"emailRecipients":[],"type":"email"}"#); + let round_tripped: ClickStackAlertChannel = serde_json::from_str(&json).unwrap(); + assert_eq!(round_tripped, default); + + // A response that drops `channel` entirely surfaces the absence instead of + // fabricating a default channel. + let response: ClickStackAlertResponse = serde_json::from_str(r#"{"name":"my-alert"}"#).unwrap(); + assert_eq!(response.channel, None); +} + +#[test] +fn deserialize_clickstack_log_source_with_metadata_materialized_views() { + let json = r#"{ + "id": "src-1", + "kind": "log", + "name": "logs", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "from": {"databaseName": "default", "tableName": "logs"}, + "timestampValueExpression": "ts", + "metadataMaterializedViews": { + "granularity": "1 hour", + "keyRollupTable": "logs_keys_1h", + "kvRollupTable": "logs_kv_1h" + } + }"#; + let src: ClickStackLogSourceResponse = serde_json::from_str(json).unwrap(); + let mv = src + .metadata_materialized_views + .expect("metadataMaterializedViews should populate"); + assert_eq!(mv.granularity.as_deref(), Some("1 hour")); + assert_eq!(mv.key_rollup_table.as_deref(), Some("logs_keys_1h")); + assert_eq!(mv.kv_rollup_table.as_deref(), Some("logs_kv_1h")); +} + +#[test] +fn deserialize_clickstack_log_source_without_id() { + // `id` is server-generated and omitted from create/update request payloads; + // it must deserialize to None and be dropped on serialize. + let json = r#"{ + "kind": "log", + "name": "logs", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "from": {"databaseName": "default", "tableName": "logs"}, + "timestampValueExpression": "ts" + }"#; + let src: ClickStackLogSource = serde_json::from_str(json).unwrap(); + assert_eq!(src.id, None); + let v = serde_json::to_value(&src).unwrap(); + assert!(v.get("id").is_none(), "id must be omitted when None"); +} + +#[test] +fn deserialize_clickstack_trace_source_default_table_select_expression() { + let json = r#"{ + "id": "trace-1", + "kind": "trace", + "name": "traces", + "connection": "conn-1", + "defaultTableSelectExpression": "Timestamp, SpanName", + "from": {"databaseName": "default", "tableName": "traces"}, + "timestampValueExpression": "Timestamp", + "durationExpression": "Duration", + "durationPrecision": 9, + "traceIdExpression": "TraceId", + "spanIdExpression": "SpanId", + "parentSpanIdExpression": "ParentSpanId", + "spanNameExpression": "SpanName", + "spanKindExpression": "SpanKind" + }"#; + let src: ClickStackTraceSource = serde_json::from_str(json).unwrap(); + assert_eq!(src.default_table_select_expression, "Timestamp, SpanName"); + let v = serde_json::to_value(&src).unwrap(); + assert_eq!(v["defaultTableSelectExpression"], "Timestamp, SpanName"); + + // `defaultTableSelectExpression` is in the spec `required[]` for trace + // sources, so the request variant types it as `String`. Responses stay + // tolerant of a server-side field drop through the response variant, where + // it is `Option`: a missing field lands as `None` instead of + // failing the whole payload (and, via the `kind`-dispatched + // `ClickStackSourceResponse` union, the whole list response). + let missing = r#"{ + "id": "trace-1", + "kind": "trace", + "name": "traces", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "traces"}, + "timestampValueExpression": "Timestamp", + "durationExpression": "Duration", + "durationPrecision": 9, + "traceIdExpression": "TraceId", + "spanIdExpression": "SpanId", + "parentSpanIdExpression": "ParentSpanId", + "spanNameExpression": "SpanName", + "spanKindExpression": "SpanKind" + }"#; + let src: ClickStackTraceSourceResponse = serde_json::from_str(missing).unwrap(); + assert_eq!(src.default_table_select_expression, None); + match serde_json::from_str::(missing).unwrap() { + ClickStackSourceResponse::ClickStackTraceSource(src) => { + assert_eq!(src.default_table_select_expression, None); + } + other => panic!("expected trace variant, got {other:?}"), + } + // The dropped field is absent from the re-serialized payload rather than + // sent back as `null`. + let v = serde_json::to_value(&src).unwrap(); + assert!(v.get("defaultTableSelectExpression").is_none()); +} + +#[test] +fn round_trip_clickstack_log_source_new_fields() { + let json = r#"{ + "id": "src-1", + "kind": "log", + "name": "logs", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "from": {"databaseName": "default", "tableName": "logs"}, + "timestampValueExpression": "ts", + "section": "Billing", + "disabled": false, + "knownColumnsListExpression": "Timestamp, Body, ServiceName", + "useTextIndexForImplicitColumn": "auto" + }"#; + let src: ClickStackLogSource = serde_json::from_str(json).unwrap(); + assert_eq!(src.section.as_deref(), Some("Billing")); + assert_eq!(src.disabled, Some(false)); + assert_eq!( + src.known_columns_list_expression.as_deref(), + Some("Timestamp, Body, ServiceName") + ); + assert_eq!( + src.use_text_index_for_implicit_column, + Some(ClickStackLogSourceUsetextindexforimplicitcolumn::Auto) + ); + let v = serde_json::to_value(&src).unwrap(); + assert_eq!(v["section"], "Billing"); + assert_eq!(v["disabled"], false); + assert_eq!( + v["knownColumnsListExpression"], + "Timestamp, Body, ServiceName" + ); + assert_eq!(v["useTextIndexForImplicitColumn"], "auto"); +} + +#[test] +fn clickstack_source_dispatches_promql_variant() { + let json = r#"{ + "id": "src-1", + "kind": "promql", + "name": "Prometheus Metrics", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "metrics"}, + "timestampValueExpression": "timestamp" + }"#; + let source: ClickStackSource = serde_json::from_str(json).unwrap(); + match source { + ClickStackSource::ClickStackPromqlSource(src) => { + assert_eq!(src.kind, ClickStackPromqlSourceKind::Promql); + assert_eq!(src.name, "Prometheus Metrics"); + assert_eq!(src.connection, "conn-1"); + assert_eq!(src.id.as_deref(), Some("src-1")); + } + other => panic!("expected Promql source, got {other}"), + } +} + +#[test] +fn clickstack_source_dispatches_log_variant() { + // Regression: a log-source payload must not be swallowed by the new Promql + // variant and must still resolve to the log-source variant. + let json = r#"{ + "id": "src-1", + "kind": "log", + "name": "logs", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "from": {"databaseName": "default", "tableName": "logs"}, + "timestampValueExpression": "ts" + }"#; + let source: ClickStackSource = serde_json::from_str(json).unwrap(); + match source { + ClickStackSource::ClickStackLogSource(src) => { + assert_eq!(src.kind, ClickStackLogSourceKind::Log); + assert_eq!(src.name, "logs"); + } + other => panic!("expected log source, got {other}"), + } +} + +#[test] +fn clickstack_source_dispatches_trace_variant() { + // Regression (PR #311 review P1-1): a spec-valid trace payload shares every + // Log-required field (defaultTableSelectExpression et al.), so untagged + // matching would greedily resolve it to the Log variant and drop trace-only + // fields. Discriminator dispatch on `kind` must route it to the Trace + // variant with its trace-only fields intact. + let json = r#"{ + "id": "trace-1", + "kind": "trace", + "name": "traces", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "traces"}, + "timestampValueExpression": "Timestamp", + "defaultTableSelectExpression": "Timestamp, SpanName", + "durationExpression": "Duration", + "durationPrecision": 9, + "parentSpanIdExpression": "ParentSpanId", + "spanIdExpression": "SpanId", + "spanKindExpression": "SpanKind", + "spanNameExpression": "SpanName", + "traceIdExpression": "TraceId" + }"#; + let source: ClickStackSource = serde_json::from_str(json).unwrap(); + match source { + ClickStackSource::ClickStackTraceSource(src) => { + assert_eq!(src.kind, ClickStackTraceSourceKind::Trace); + assert_eq!(src.name, "traces"); + assert_eq!(src.default_table_select_expression, "Timestamp, SpanName"); + assert_eq!(src.duration_expression, "Duration"); + assert_eq!(src.duration_precision, 9); + assert_eq!(src.parent_span_id_expression, "ParentSpanId"); + assert_eq!(src.span_id_expression, "SpanId"); + assert_eq!(src.span_kind_expression, "SpanKind"); + assert_eq!(src.span_name_expression, "SpanName"); + assert_eq!(src.trace_id_expression, "TraceId"); + } + other => panic!("expected trace source, got {other}"), + } +} + +#[test] +fn clickstack_source_dispatches_metric_variant() { + let json = r#"{ + "id": "metric-1", + "kind": "metric", + "name": "metrics", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "metrics"}, + "timestampValueExpression": "TimeUnix", + "resourceAttributesExpression": "ResourceAttributes", + "metricTables": { + "gauge": "otel_metrics_gauge", + "histogram": "otel_metrics_histogram", + "sum": "otel_metrics_sum", + "summary": "otel_metrics_summary", + "exponential histogram": "otel_metrics_exponential_histogram" + } + }"#; + let source: ClickStackSource = serde_json::from_str(json).unwrap(); + match source { + ClickStackSource::ClickStackMetricSource(src) => { + assert_eq!(src.kind, ClickStackMetricSourceKind::Metric); + assert_eq!(src.name, "metrics"); + assert_eq!(src.resource_attributes_expression, "ResourceAttributes"); + assert_eq!(src.metric_tables.gauge, "otel_metrics_gauge"); + } + other => panic!("expected metric source, got {other}"), + } +} + +#[test] +fn clickstack_source_dispatches_session_variant() { + let json = r#"{ + "id": "session-1", + "kind": "session", + "name": "sessions", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "sessions"}, + "traceSourceId": "trace-1" + }"#; + let source: ClickStackSource = serde_json::from_str(json).unwrap(); + match source { + ClickStackSource::ClickStackSessionSource(src) => { + assert_eq!(src.kind, ClickStackSessionSourceKind::Session); + assert_eq!(src.name, "sessions"); + assert_eq!(src.trace_source_id, "trace-1"); + } + other => panic!("expected session source, got {other}"), + } +} + +#[test] +fn clickstack_source_unknown_kind_round_trips() { + let json = r#"{"kind":"future_kind","name":"x"}"#; + assert_unknown_variant_round_trips(json, |s: &ClickStackSource| { + matches!(s, ClickStackSource::Unknown(_)) + }); +} + +#[test] +fn clickstack_source_serializes_as_inner_log_struct() { + let log = ClickStackLogSource { + kind: ClickStackLogSourceKind::Log, + name: "logs".to_string(), + connection: "conn-1".to_string(), + default_table_select_expression: "*".to_string(), + from: ClickStackSourceFrom { + database_name: "default".to_string(), + table_name: "logs".to_string(), + }, + timestamp_value_expression: "ts".to_string(), + ..Default::default() + }; + let source = ClickStackSource::ClickStackLogSource(log.clone()); + assert_eq!( + serde_json::to_value(&source).unwrap(), + serde_json::to_value(&log).unwrap() + ); +} + +#[test] +fn clickstack_source_response_dispatches_on_kind_alone() { + // The response variants are all-`Option`, so every one of them matches any + // JSON object: under `untagged` shape matching the first arm would swallow + // all five kinds. Dispatch reads `kind` off the raw JSON instead, so a + // payload carrying nothing but its discriminator still resolves to the right + // variant. + for (kind, expected) in [ + ("log", "ClickStackLogSource"), + ("trace", "ClickStackTraceSource"), + ("metric", "ClickStackMetricSource"), + ("session", "ClickStackSessionSource"), + ("promql", "ClickStackPromqlSource"), + ] { + let json = format!(r#"{{"kind":"{kind}"}}"#); + let source: ClickStackSourceResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(source.to_string(), expected, "wrong variant for {kind}"); + } +} + +#[test] +fn clickstack_source_response_treats_dropped_and_null_fields_as_absent() { + // Both halves of the tolerance contract on one payload: `connection` is sent + // as an explicit `null` (which `serde(default)` would not have absorbed) and + // every other field of the schema is dropped outright. + let json = r#"{"kind":"log","connection":null,"name":"logs"}"#; + let source: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); + let ClickStackSourceResponse::ClickStackLogSource(log) = source else { + panic!("expected the log variant"); + }; + assert_eq!(log.connection, None); + assert_eq!(log.from, None); + assert_eq!(log.timestamp_value_expression, None); + assert_eq!(log.name.as_deref(), Some("logs")); + + // Absent fields are omitted on the way out, not re-emitted as `null`. + let v = serde_json::to_value(&log).unwrap(); + assert_eq!(v, serde_json::json!({"kind": "log", "name": "logs"})); +} + +#[test] +fn clickstack_source_response_unknown_kind_round_trips() { + let json = r#"{"kind":"future_kind","name":"x"}"#; + assert_unknown_variant_round_trips(json, |s: &ClickStackSourceResponse| { + matches!(s, ClickStackSourceResponse::Unknown(_)) + }); +} + +#[test] +fn clickstack_source_response_nested_objects_are_tolerant() { + // The nested objects of a source are response variants too, so a dropped + // field inside `from`, `filterSettings` or `materializedViews` does not fail + // the source. + let json = r#"{ + "kind": "log", + "from": {"databaseName": "default"}, + "filterSettings": {"columns": [{"name": "ServiceName"}]}, + "materializedViews": [{"tableName": "logs_1h", "aggregatedColumns": [{"aggFn": "sum"}]}], + "querySettings": [{"setting": "max_threads"}] + }"#; + let source: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); + let ClickStackSourceResponse::ClickStackLogSource(log) = source else { + panic!("expected the log variant"); + }; + let from = log.from.expect("from present"); + assert_eq!(from.database_name.as_deref(), Some("default")); + assert_eq!(from.table_name, None); + let filter_settings = log.filter_settings.expect("filterSettings present"); + assert_eq!(filter_settings.table_name, None); + let columns = filter_settings.columns.expect("columns present"); + assert_eq!(columns[0].name.as_deref(), Some("ServiceName")); + assert_eq!(columns[0].label, None); + let views = log.materialized_views.expect("materializedViews present"); + assert_eq!(views[0].min_granularity, None); + let aggregated = views[0] + .aggregated_columns + .as_deref() + .expect("aggregatedColumns present"); + assert_eq!(aggregated[0].agg_fn.as_deref(), Some("sum")); + assert_eq!(aggregated[0].mv_column, None); + let query_settings = log.query_settings.expect("querySettings present"); + assert_eq!(query_settings[0].value, None); +} + +#[test] +fn clickstack_source_try_from_response_converts_every_kind() { + // One spec-complete payload per kind, each carrying the nested objects that + // kind owns, so every conversion in the source tree is exercised: a source + // fetched and written back unchanged must produce the JSON it came from. + let payloads = [ + serde_json::json!({ + "kind": "log", + "name": "logs", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "from": {"databaseName": "default", "tableName": "logs"}, + "timestampValueExpression": "ts", + "filterSettings": { + "columns": [{"label": "Service", "name": "ServiceName"}], + "databaseName": "default", + "tableName": "logs_filters", + }, + "highlightedRowAttributeExpressions": [{"sqlExpression": "ServiceName"}], + "materializedViews": [{ + "aggregatedColumns": [{"aggFn": "sum", "mvColumn": "count"}], + "databaseName": "default", + "dimensionColumns": "ServiceName", + "minGranularity": "1 hour", + "tableName": "logs_1h", + "timestampColumn": "ts", + }], + "metadataMaterializedViews": { + "granularity": "1 hour", + "keyRollupTable": "logs_keys_1h", + "kvRollupTable": "logs_kv_1h", + }, + "orderByExpression": "Timestamp DESC", + "querySettings": [{"setting": "max_threads", "value": "4"}], + }), + serde_json::json!({ + "kind": "trace", + "name": "traces", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "durationExpression": "Duration", + "durationPrecision": 9, + "from": {"databaseName": "default", "tableName": "traces"}, + "parentSpanIdExpression": "ParentSpanId", + "spanIdExpression": "SpanId", + "spanKindExpression": "SpanKind", + "spanNameExpression": "SpanName", + "timestampValueExpression": "Timestamp", + "traceIdExpression": "TraceId", + "displayedTimestampValueExpression": "Timestamp", + "orderByExpression": "Timestamp DESC", + "sampleRateExpression": "SampleRate", + "spanLinksValueExpression": "Links", + "metadataMaterializedViews": { + "granularity": "1 hour", + "keyRollupTable": "traces_keys_1h", + "kvRollupTable": "traces_kv_1h", + }, + }), + serde_json::json!({ + "kind": "metric", + "name": "metrics", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "metrics"}, + "metricTables": { + "exponential histogram": "otel_metrics_exponential_histogram", + "gauge": "otel_metrics_gauge", + "histogram": "otel_metrics_histogram", + "sum": "otel_metrics_sum", + "summary": "otel_metrics_summary", + }, + "resourceAttributesExpression": "ResourceAttributes", + "timestampValueExpression": "TimeUnix", + }), + serde_json::json!({ + "kind": "session", + "name": "sessions", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "sessions"}, + "resourceAttributesExpression": "ResourceAttributes", + "traceSourceId": "trace-1", + }), + serde_json::json!({ + "kind": "promql", + "name": "prometheus", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "metrics"}, + "timestampValueExpression": "timestamp", + }), + ]; + + for payload in payloads { + let response: ClickStackSourceResponse = serde_json::from_value(payload.clone()).unwrap(); + let request = ClickStackSource::try_from(response) + .unwrap_or_else(|e| panic!("{} should convert: {e}", payload["kind"])); + assert_eq!(serde_json::to_value(&request).unwrap(), payload); + } +} + +#[test] +fn clickstack_source_try_from_response_converts_a_complete_source() { + let json = r#"{ + "id": "src-1", + "kind": "log", + "name": "logs", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "from": {"databaseName": "default", "tableName": "logs"}, + "timestampValueExpression": "ts", + "querySettings": [{"setting": "max_threads", "value": "4"}] + }"#; + let response: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); + let request = ClickStackSource::try_from(response).expect("conversion should succeed"); + let ClickStackSource::ClickStackLogSource(log) = &request else { + panic!("expected the log variant"); + }; + assert_eq!(log.connection, "conn-1"); + assert_eq!(log.from.table_name, "logs"); + assert_eq!( + log.query_settings.as_deref(), + Some( + [ClickStackQuerySetting { + setting: "max_threads".to_string(), + value: "4".to_string(), + }] + .as_slice() + ) + ); + // A write-back of an untouched source is byte-identical to what was fetched. + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::from_str::(json).unwrap() + ); +} + +#[test] +fn clickstack_source_try_from_response_names_every_missing_required_field() { + let response: ClickStackSourceResponse = + serde_json::from_str(r#"{"kind":"log","name":"logs"}"#).unwrap(); + let error = ClickStackSource::try_from(response).expect_err("conversion should fail"); + assert_eq!( + error.fields(), + [ + "connection", + "defaultTableSelectExpression", + "from", + "timestampValueExpression" + ] + ); + assert_eq!( + error.to_string(), + "the API response is missing required field(s): connection, \ + defaultTableSelectExpression, from, timestampValueExpression" + ); +} + +#[test] +fn clickstack_source_try_from_response_reports_a_nested_missing_field() { + // A nested object reports its own wire name: `from` is present, so the + // failure is `tableName` inside it. + let json = r#"{ + "kind": "log", + "name": "logs", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "from": {"databaseName": "default"}, + "timestampValueExpression": "ts" + }"#; + let response: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); + let error = ClickStackSource::try_from(response).expect_err("conversion should fail"); + assert_eq!(error.fields(), ["tableName"]); + + // The same holds for an element of a nested list. + let json = r#"{ + "kind": "log", + "name": "logs", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "from": {"databaseName": "default", "tableName": "logs"}, + "timestampValueExpression": "ts", + "querySettings": [{"setting": "max_threads"}] + }"#; + let response: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); + let error = ClickStackSource::try_from(response).expect_err("conversion should fail"); + assert_eq!(error.fields(), ["value"]); +} + +#[test] +fn clickstack_source_try_from_response_passes_an_unknown_kind_through() { + // A source kind this crate does not model must still be writable back: the + // request union's `Unknown` arm holds the raw payload and serializes it + // verbatim. + let json = r#"{"kind":"future_kind","name":"x"}"#; + let response: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); + let request = ClickStackSource::try_from(response).expect("Unknown converts losslessly"); + assert!(matches!(request, ClickStackSource::Unknown(_))); + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::from_str::(json).unwrap() + ); +} + +#[test] +fn shared_clickstack_source_types_stay_strict_on_the_request_side() { + // Sources are sent as well as returned, so each type in the source tree + // splits: the request variant keeps the schema's required fields as `T` and + // rejects a payload that omits one, while the response variant accepts any + // object. + macro_rules! assert_split_strictness { + ($($request:ty => $response:ty),+ $(,)?) => { + $( + assert!( + serde_json::from_str::<$request>("{}").is_err(), + "{} accepted a payload missing its required fields", + stringify!($request), + ); + assert_eq!( + serde_json::from_str::<$response>("{}").unwrap(), + <$response>::default(), + "{} rejected an empty payload", + stringify!($response), + ); + )+ + }; + } + + assert_split_strictness!( + ClickStackAggregatedColumn => ClickStackAggregatedColumnResponse, + ClickStackClickHouseCASLPermission => ClickStackClickHouseCASLPermissionResponse, + ClickStackClickHouseCASLPermissionConditions => ClickStackClickHouseCASLPermissionConditionsResponse, + ClickStackClickHouseCASLPermissionConditionsFrom => ClickStackClickHouseCASLPermissionConditionsFromResponse, + ClickStackMongoDBCASLPermission => ClickStackMongoDBCASLPermissionResponse, + ClickStackQueryExpressionFilter => ClickStackQueryExpressionFilterResponse, + ClickStackStaticListFilter => ClickStackStaticListFilterResponse, + ClickStackPrometheusLabelFilter => ClickStackPrometheusLabelFilterResponse, + ClickStackSourceFilter => ClickStackSourceFilterResponse, + ClickStackFilterSettingsColumn => ClickStackFilterSettingsColumnResponse, + ClickStackHighlightedAttributeExpression => ClickStackHighlightedAttributeExpressionResponse, + ClickStackLogSource => ClickStackLogSourceResponse, + ClickStackLogSourceMetadataMaterializedViews => ClickStackLogSourceMetadataMaterializedViewsResponse, + ClickStackMaterializedView => ClickStackMaterializedViewResponse, + ClickStackMetricSource => ClickStackMetricSourceResponse, + ClickStackMetricSourceFrom => ClickStackMetricSourceFromResponse, + ClickStackMetricTables => ClickStackMetricTablesResponse, + ClickStackPromqlSource => ClickStackPromqlSourceResponse, + ClickStackQuerySetting => ClickStackQuerySettingResponse, + ClickStackSqlSavedFilterValue => ClickStackSqlSavedFilterValueResponse, + ClickStackVariableSavedFilterValue => ClickStackVariableSavedFilterValueResponse, + ClickStackFormula => ClickStackFormulaResponse, + ClickStackSavedSearchFilter => ClickStackSavedSearchFilterResponse, + ClickStackSessionSource => ClickStackSessionSourceResponse, + ClickStackSourceFilterSettings => ClickStackSourceFilterSettingsResponse, + ClickStackSourceFrom => ClickStackSourceFromResponse, + ClickStackTraceSource => ClickStackTraceSourceResponse, + ClickStackTraceSourceMetadataMaterializedViews => ClickStackTraceSourceMetadataMaterializedViewsResponse, + ); +} + +#[test] +fn clickstack_response_only_models_accept_an_empty_payload() { + // Connections, roles and saved searches are only ever returned — the create + // and update bodies are separate schemas — so they are all-`Option` in place + // instead of splitting, and none of their fields can fail a response. + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickStackConnection::default() + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickStackRole::default() + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickStackSavedSearch::default() + ); + // Their request counterparts stay strict. + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::("{}").is_err()); +} + +#[test] +fn shared_clickstack_dashboard_types_stay_strict_on_the_request_side() { + // Dashboard containers, tiles' chart configs, select items, number formats, + // color conditions, on-click targets, alert channels and alert chart configs + // are sent as well as returned, so each splits: the request variant keeps + // the schema's required fields as `T` and rejects a payload that omits one, + // while the response variant accepts any object. + macro_rules! assert_split_strictness { + ($($request:ty => $response:ty),+ $(,)?) => { + $( + assert!( + serde_json::from_str::<$request>("{}").is_err(), + "{} accepted a payload missing its required fields", + stringify!($request), + ); + assert_eq!( + serde_json::from_str::<$response>("{}").unwrap(), + <$response>::default(), + "{} rejected an empty payload", + stringify!($response), + ); + )+ + }; + } + + assert_split_strictness!( + ClickStackAlertBarBuilderChartConfig => ClickStackAlertBarBuilderChartConfigResponse, + ClickStackAlertBarRawSqlChartConfig => ClickStackAlertBarRawSqlChartConfigResponse, + ClickStackAlertLineBuilderChartConfig => ClickStackAlertLineBuilderChartConfigResponse, + ClickStackAlertLineRawSqlChartConfig => ClickStackAlertLineRawSqlChartConfigResponse, + ClickStackAlertNumberBuilderChartConfig => ClickStackAlertNumberBuilderChartConfigResponse, + ClickStackAlertNumberRawSqlChartConfig => ClickStackAlertNumberRawSqlChartConfigResponse, + ClickStackAlertStackedLineBuilderChartConfig => ClickStackAlertStackedLineBuilderChartConfigResponse, + ClickStackAlertStackedLineRawSqlChartConfig => ClickStackAlertStackedLineRawSqlChartConfigResponse, + ClickStackAlertChannelEmail => ClickStackAlertChannelEmailResponse, + ClickStackAlertChannelWebhook => ClickStackAlertChannelWebhookResponse, + ClickStackBackgroundChart => ClickStackBackgroundChartResponse, + ClickStackBarBuilderChartConfig => ClickStackBarBuilderChartConfigResponse, + ClickStackBarRawSqlChartConfig => ClickStackBarRawSqlChartConfigResponse, + ClickStackBetweenColorCondition => ClickStackBetweenColorConditionResponse, + ClickStackCategoricalBarBuilderChartConfig => ClickStackCategoricalBarBuilderChartConfigResponse, + ClickStackCategoricalBarRawSqlChartConfig => ClickStackCategoricalBarRawSqlChartConfigResponse, + ClickStackDashboardContainer => ClickStackDashboardContainerResponse, + ClickStackDashboardContainerTab => ClickStackDashboardContainerTabResponse, + ClickStackEqualityColorCondition => ClickStackEqualityColorConditionResponse, + ClickStackEventPatternsChartConfig => ClickStackEventPatternsChartConfigResponse, + ClickStackHeatmapDistributionChartConfig => ClickStackHeatmapDistributionChartConfigResponse, + ClickStackHeatmapSeriesChartConfig => ClickStackHeatmapSeriesChartConfigResponse, + ClickStackHeatmapSelectItem => ClickStackHeatmapSelectItemResponse, + ClickStackLineBuilderChartConfig => ClickStackLineBuilderChartConfigResponse, + ClickStackLineRawSqlChartConfig => ClickStackLineRawSqlChartConfigResponse, + ClickStackMarkdownChartConfig => ClickStackMarkdownChartConfigResponse, + ClickStackNumberBuilderChartConfig => ClickStackNumberBuilderChartConfigResponse, + ClickStackNumberFormat => ClickStackNumberFormatResponse, + ClickStackNumberRawSqlChartConfig => ClickStackNumberRawSqlChartConfigResponse, + ClickStackNumericColorCondition => ClickStackNumericColorConditionResponse, + ClickStackOnClickDashboard => ClickStackOnClickDashboardResponse, + ClickStackOnClickExternal => ClickStackOnClickExternalResponse, + ClickStackOnClickFilterTemplate => ClickStackOnClickFilterTemplateResponse, + ClickStackOnClickSearch => ClickStackOnClickSearchResponse, + ClickStackOnClickTargetIdVariant => ClickStackOnClickTargetIdVariantResponse, + ClickStackOnClickTargetTemplateVariant => ClickStackOnClickTargetTemplateVariantResponse, + ClickStackPieBuilderChartConfig => ClickStackPieBuilderChartConfigResponse, + ClickStackPieRawSqlChartConfig => ClickStackPieRawSqlChartConfigResponse, + ClickStackSearchChartConfig => ClickStackSearchChartConfigResponse, + ClickStackSelectItem => ClickStackSelectItemResponse, + ClickStackStackedLineBuilderChartConfig => ClickStackStackedLineBuilderChartConfigResponse, + ClickStackStackedLineRawSqlChartConfig => ClickStackStackedLineRawSqlChartConfigResponse, + ClickStackTableBuilderChartConfig => ClickStackTableBuilderChartConfigResponse, + ClickStackTableRawSqlChartConfig => ClickStackTableRawSqlChartConfigResponse, + ); +} + +#[test] +fn clickstack_alert_and_dashboard_response_models_accept_an_empty_payload() { + // Alerts, dashboards, tiles, validation results and webhooks are only ever + // returned — their request bodies are separate schemas — so they are + // all-`Option` in place instead of splitting, and none of their fields can + // fail a response. + macro_rules! assert_accepts_empty { + ($($model:ty),+ $(,)?) => { + $( + assert_eq!( + serde_json::from_str::<$model>("{}").unwrap(), + <$model>::default(), + "{} rejected an empty payload", + stringify!($model), + ); + )+ + }; + } + + assert_accepts_empty!( + ClickStackAlertExecutionError, + ClickStackAlertResponse, + ClickStackAlertSilenced, + ClickStackDashboardResponse, + ClickStackGenericWebhook, + ClickStackIncidentIOWebhook, + ClickStackPagerDutyAPIWebhook, + ClickStackSlackAPIWebhook, + ClickStackSlackWebhook, + ClickStackTileOutput, + ClickStackValidateDashboardError, + ClickStackValidateDashboardResponse, + ClickStackValidateWebhookResponse, + ClickStackValidateWebhookResponseErrorsItem, + ); +} + +#[test] +fn clickstack_dashboard_response_null_fields_deserialize_to_none() { + // An explicit JSON `null` — not just a missing key — must land as `None`: + // `Option` absorbs it natively, which `serde(default)` never did. + let json = r#"{"id":null,"name":null,"tiles":null,"filters":null,"tags":null}"#; + let dash: ClickStackDashboardResponse = serde_json::from_str(json).unwrap(); + assert_eq!(dash, ClickStackDashboardResponse::default()); + // Absent fields are omitted on serialize, not emitted as `null`. + assert_eq!(serde_json::to_value(&dash).unwrap(), serde_json::json!({})); +} + +#[test] +fn clickstack_tile_config_response_raw_sql_body_without_config_type_falls_to_sub_union_unknown() { + // Every field of a response builder variant is `Option`, so the builder arm + // is total and would absorb a raw-SQL payload whose server dropped + // `configType`, silently retyping it and losing `connectionId` and + // `sqlTemplate`. The `none unless` guard routes it to Unknown, which keeps + // the payload verbatim. + let json = r#"{"displayType":"line","connectionId":"conn-1","sqlTemplate":"SELECT 1"}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfigResponse| { + matches!( + cfg, + ClickStackTileConfigResponse::ClickStackLineChartConfig( + ClickStackLineChartConfigResponse::Unknown(_) + ) + ) + }); +} + +#[test] +fn clickstack_tile_config_response_dispatches_raw_sql_variant() { + // A `configType: "sql"` payload dispatches to the raw-SQL response variant + // even when the raw-SQL-required fields are missing: response tolerance is + // per-field, not per-variant. + let json = r#"{"displayType":"line","configType":"sql"}"#; + let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfigResponse::ClickStackLineChartConfig( + ClickStackLineChartConfigResponse::ClickStackLineRawSqlChartConfig(r), + ) => { + assert_eq!(r.connection_id, None); + assert_eq!(r.sql_template, None); + } + other => panic!("expected line raw-SQL variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_response_changed_field_shape_falls_to_sub_union_unknown() { + // A recognized discriminator whose payload no longer fits the variant — + // here `sqlTemplate` as a number — must not fail the response: the element + // lands in the lossless Unknown catch-all and round-trips verbatim. + let json = r#"{"displayType":"line","configType":"sql","sqlTemplate":5}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfigResponse| { + matches!( + cfg, + ClickStackTileConfigResponse::ClickStackLineChartConfig( + ClickStackLineChartConfigResponse::Unknown(_) + ) + ) + }); +} + +#[test] +fn clickstack_tile_config_response_unknown_display_type_round_trips() { + let json = r#"{"displayType":"hologram","select":[]}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfigResponse| { + matches!(cfg, ClickStackTileConfigResponse::Unknown(_)) + }); +} + +#[test] +fn clickstack_alert_channel_response_dispatches_and_absorbs_changed_shapes() { + // Known discriminators dispatch to their response variants. + let email: ClickStackAlertChannelResponse = + serde_json::from_str(r#"{"type":"email","emailRecipients":["a@b.c"]}"#).unwrap(); + match email { + ClickStackAlertChannelResponse::ClickStackAlertChannelEmail(v) => { + assert_eq!( + v.email_recipients.as_deref(), + Some(&["a@b.c".to_string()][..]) + ); + } + other => panic!("expected email variant, got {other}"), + } + // A recognized `type` whose payload no longer fits the variant — here + // `emailRecipients` as a string — lands in Unknown intact. + let json = r#"{"type":"email","emailRecipients":"a@b.c"}"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChannelResponse| { + matches!(c, ClickStackAlertChannelResponse::Unknown(_)) + }); + // As does an unrecognized `type`. + let json = r#"{"type":"future_channel","foo":1}"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChannelResponse| { + matches!(c, ClickStackAlertChannelResponse::Unknown(_)) + }); +} + +#[test] +fn clickstack_on_click_response_dispatches_on_type_and_mode() { + let json = r#"{"type":"external","urlTemplate":"https://example.com/{{id}}"}"#; + let on_click: ClickStackOnClickResponse = serde_json::from_str(json).unwrap(); + match on_click { + ClickStackOnClickResponse::ClickStackOnClickExternal(e) => { + assert_eq!( + e.url_template.as_deref(), + Some("https://example.com/{{id}}") + ); + } + other => panic!("expected external variant, got {other}"), + } + // A search on-click whose server dropped `target` still dispatches; the + // absence is a `None`, not a failure. + let json = r#"{"type":"search"}"#; + let on_click: ClickStackOnClickResponse = serde_json::from_str(json).unwrap(); + match on_click { + ClickStackOnClickResponse::ClickStackOnClickSearch(s) => assert_eq!(s.target, None), + other => panic!("expected search variant, got {other}"), + } + let json = r#"{"mode":"template","template":"{{rowId}}"}"#; + let target: ClickStackOnClickTargetResponse = serde_json::from_str(json).unwrap(); + match target { + ClickStackOnClickTargetResponse::ClickStackOnClickTargetTemplateVariant(t) => { + assert_eq!(t.template.as_deref(), Some("{{rowId}}")); + } + other => panic!("expected template variant, got {other}"), + } + // Missing discriminators land in Unknown: these unions have no absence arm. + let json = r#"{"urlTemplate":"https://example.com"}"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackOnClickResponse| { + matches!(c, ClickStackOnClickResponse::Unknown(_)) + }); + let json = r#"{"template":"{{rowId}}"}"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackOnClickTargetResponse| { + matches!(c, ClickStackOnClickTargetResponse::Unknown(_)) + }); +} + +#[test] +fn clickstack_number_tile_color_condition_response_dispatches_on_operator() { + let json = r#"{"operator":"between","value":[1.0, 2.0]}"#; + let cond: ClickStackNumberTileColorConditionResponse = serde_json::from_str(json).unwrap(); + match cond { + ClickStackNumberTileColorConditionResponse::ClickStackBetweenColorCondition(b) => { + assert_eq!(b.value.as_deref(), Some(&[1.0, 2.0][..])); + assert_eq!(b.color, None); + } + other => panic!("expected between variant, got {other}"), + } + let json = r#"{"operator":"someday","value":7}"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackNumberTileColorConditionResponse| { + matches!(c, ClickStackNumberTileColorConditionResponse::Unknown(_)) + }); +} + +#[test] +fn deserialize_clickstack_alert_with_note() { + let json = r#"{ + "id": "alert-1", + "name": "High CPU", + "note": "investigate runaway queries" + }"#; + let alert: ClickStackAlertResponse = serde_json::from_str(json).unwrap(); + assert_eq!(alert.note.as_deref(), Some("investigate runaway queries")); + // Round-trip the optional `note` to confirm it serializes (no rename, but + // its skip_serializing_if=None gate must let Some(_) through). + let v = serde_json::to_value(&alert).unwrap(); + assert_eq!(v["note"], "investigate runaway queries"); +} + +#[test] +fn clickstack_alert_num_consecutive_windows_present_and_absent() { + let json = r#"{ + "id": "alert-1", + "name": "High CPU", + "numConsecutiveWindows": 3 + }"#; + let alert: ClickStackAlertResponse = serde_json::from_str(json).unwrap(); + assert_eq!(alert.num_consecutive_windows, Some(3)); + let v = serde_json::to_value(&alert).unwrap(); + assert_eq!(v["numConsecutiveWindows"], 3); + + // Absent -> None -> dropped by skip_serializing_if. + let alert = ClickStackAlertResponse::default(); + assert_eq!(alert.num_consecutive_windows, None); + let v = serde_json::to_value(&alert).unwrap(); + assert!(v.get("numConsecutiveWindows").is_none()); +} + +#[test] +fn clickstack_create_alert_num_consecutive_windows_round_trip() { + let v = serde_json::to_value(ClickStackCreateAlertRequest { + num_consecutive_windows: Some(5), + ..Default::default() + }) + .unwrap(); + assert_eq!(v["numConsecutiveWindows"], 5); + let v = serde_json::to_value(ClickStackCreateAlertRequest::default()).unwrap(); + assert!(v.get("numConsecutiveWindows").is_none()); +} + +#[test] +fn clickstack_update_alert_num_consecutive_windows_round_trip() { + let v = serde_json::to_value(ClickStackUpdateAlertRequest { + num_consecutive_windows: Some(7), + ..Default::default() + }) + .unwrap(); + assert_eq!(v["numConsecutiveWindows"], 7); + let v = serde_json::to_value(ClickStackUpdateAlertRequest::default()).unwrap(); + assert!(v.get("numConsecutiveWindows").is_none()); +} + +#[test] +fn clickstack_filter_applies_to_source_ids_round_trip() { + let json = r#"{ + "expression": "level = 'error'", + "id": "f-1", + "name": "errors", + "sourceId": "src-1", + "type": "QUERY_EXPRESSION", + "appliesToSourceIds": ["src-1", "src-2"] + }"#; + let filter: ClickStackFilter = serde_json::from_str(json).unwrap(); + let ClickStackFilter::ClickStackQueryExpressionFilter(filter) = filter else { + panic!("expected the query-expression variant, got {filter:?}"); + }; + assert_eq!( + filter.applies_to_source_ids, + Some(vec!["src-1".to_string(), "src-2".to_string()]) + ); + let v = serde_json::to_value(&filter).unwrap(); + assert_eq!(v["appliesToSourceIds"][0], "src-1"); + assert_eq!(v["appliesToSourceIds"][1], "src-2"); + + // Absent -> None -> dropped. + let filter = ClickStackQueryExpressionFilter::default(); + assert_eq!(filter.applies_to_source_ids, None); + let v = serde_json::to_value(&filter).unwrap(); + assert!(v.get("appliesToSourceIds").is_none()); +} + +#[test] +fn clickstack_filter_input_applies_to_source_ids_round_trip() { + let json = r#"{ + "expression": "level = 'error'", + "name": "errors", + "sourceId": "src-1", + "type": "QUERY_EXPRESSION", + "appliesToSourceIds": ["src-9"] + }"#; + let filter: ClickStackFilterInput = serde_json::from_str(json).unwrap(); + let ClickStackFilterInput::ClickStackQueryExpressionFilterInput(filter) = filter else { + panic!("expected the query-expression variant, got {filter:?}"); + }; + assert_eq!( + filter.applies_to_source_ids, + Some(vec!["src-9".to_string()]) + ); + let v = serde_json::to_value(&filter).unwrap(); + assert_eq!(v["appliesToSourceIds"][0], "src-9"); + + let v = serde_json::to_value(ClickStackQueryExpressionFilterInput::default()).unwrap(); + assert!(v.get("appliesToSourceIds").is_none()); +} + +#[test] +fn clickstack_alert_response_state_pending() { + let parsed: ClickStackAlertResponseState = serde_json::from_str("\"PENDING\"").unwrap(); + assert_eq!(parsed, ClickStackAlertResponseState::PENDING); + assert_eq!(parsed.to_string(), "PENDING"); + assert_eq!(serde_json::to_value(&parsed).unwrap(), "PENDING"); +} + +#[test] +fn clickstack_number_tile_color_condition_numeric_variant() { + // The manual `operator`-keyed dispatch routes `gt`/`gte`/`lt`/`lte` to the + // numeric variant. + let json = r#"{"operator": "gt", "value": 100, "color": "chart-red"}"#; + let cond: ClickStackNumberTileColorCondition = serde_json::from_str(json).unwrap(); + match cond { + ClickStackNumberTileColorCondition::ClickStackNumericColorCondition(c) => { + assert_eq!(c.operator, ClickStackNumericColorConditionOperator::Gt); + assert_eq!(c.value, 100.0); + assert_eq!(c.color, ClickStackChartColor::Chart_red); + } + other => panic!("expected numeric variant, got {other}"), + } +} + +#[test] +fn clickstack_number_tile_color_condition_between_variant() { + // The manual dispatch routes the `between` operator, with its inclusive + // [min, max] array value, to the between variant. + let json = r#"{"operator": "between", "value": [100, 500], "color": "chart-warning"}"#; + let cond: ClickStackNumberTileColorCondition = serde_json::from_str(json).unwrap(); + match cond { + ClickStackNumberTileColorCondition::ClickStackBetweenColorCondition(c) => { + assert_eq!(c.operator, ClickStackBetweenColorConditionOperator::Between); + assert_eq!(c.value, vec![100.0, 500.0]); + assert_eq!(c.color, ClickStackChartColor::Chart_warning); + } + other => panic!("expected between variant, got {other}"), + } +} + +#[test] +fn clickstack_number_tile_color_condition_equality_string_value() { + // The manual dispatch routes `eq` to the equality variant, whose value + // accepts strings as well as numbers. + let json = + r#"{"operator": "eq", "value": "healthy", "color": "chart-success", "label": "Healthy"}"#; + let cond: ClickStackNumberTileColorCondition = serde_json::from_str(json).unwrap(); + match cond { + ClickStackNumberTileColorCondition::ClickStackEqualityColorCondition(c) => { + assert_eq!(c.operator, ClickStackEqualityColorConditionOperator::Eq); + assert_eq!(c.value, serde_json::json!("healthy")); + assert_eq!(c.color, ClickStackChartColor::Chart_success); + assert_eq!(c.label.as_deref(), Some("Healthy")); + } + other => panic!("expected equality variant, got {other}"), + } +} + +#[test] +fn clickstack_number_tile_color_condition_equality_numeric_value() { + // A numeric-valued `eq` is structurally identical to a numeric condition; + // only the `operator`-keyed dispatch routes it to the equality variant + // rather than the numeric one. + let json = r#"{"operator": "eq", "value": 42, "color": "chart-error"}"#; + let cond: ClickStackNumberTileColorCondition = serde_json::from_str(json).unwrap(); + match cond { + ClickStackNumberTileColorCondition::ClickStackEqualityColorCondition(c) => { + assert_eq!(c.operator, ClickStackEqualityColorConditionOperator::Eq); + assert_eq!(c.value.as_f64(), Some(42.0)); + assert_eq!(c.color, ClickStackChartColor::Chart_error); + } + other => panic!("expected equality variant, got {other}"), + } +} + +#[test] +fn clickstack_number_tile_color_condition_dispatches_every_operator() { + // Exhaustive guard over the `operator`-keyed dispatch: every wire value in the + // discriminated_union! invocation must route to its concrete variant, never the + // Unknown catch-all. A typo in any operator literal in the macro ("gte"/"lt"/ + // "lte"/"neq"/...) would silently misroute a valid payload to Unknown, and this + // table would fail on that operator. The seven values are cross-checked against + // the enum members of the three constituent schemas in the OpenAPI snapshot. + #[derive(Debug, PartialEq)] + enum Variant { + Numeric, + Between, + Equality, + } + + let cases: &[(&str, serde_json::Value, Variant)] = &[ + ("gt", serde_json::json!(100), Variant::Numeric), + ("gte", serde_json::json!(100), Variant::Numeric), + ("lt", serde_json::json!(100), Variant::Numeric), + ("lte", serde_json::json!(100), Variant::Numeric), + ("between", serde_json::json!([100, 500]), Variant::Between), + ("eq", serde_json::json!(42), Variant::Equality), + ("neq", serde_json::json!("healthy"), Variant::Equality), + ]; + + for (operator, value, expected) in cases { + let json = serde_json::json!({ + "operator": operator, + "value": value, + "color": "chart-red", + }); + let cond: ClickStackNumberTileColorCondition = serde_json::from_value(json) + .unwrap_or_else(|e| panic!("operator {operator:?} failed to deserialize: {e}")); + match (&cond, expected) { + ( + ClickStackNumberTileColorCondition::ClickStackNumericColorCondition(c), + Variant::Numeric, + ) => assert_eq!(c.operator.to_string(), *operator), + ( + ClickStackNumberTileColorCondition::ClickStackBetweenColorCondition(c), + Variant::Between, + ) => assert_eq!(c.operator.to_string(), *operator), + ( + ClickStackNumberTileColorCondition::ClickStackEqualityColorCondition(c), + Variant::Equality, + ) => assert_eq!(c.operator.to_string(), *operator), + (other, _) => { + panic!("operator {operator:?} expected {expected:?} variant, got {other}") + } + } + assert!( + !matches!(cond, ClickStackNumberTileColorCondition::Unknown(_)), + "operator {operator:?} misrouted to the Unknown catch-all" + ); + } +} + +#[test] +fn clickstack_number_tile_color_condition_unknown_operator_round_trip() { + // An unrecognized `operator` discriminator falls back to the Unknown + // variant, which now stores the raw JSON object and round-trips faithfully. + let json = r#"{"operator":"contains","value":"warn","color":"chart-red"}"#; + let cond: ClickStackNumberTileColorCondition = serde_json::from_str(json).unwrap(); + match &cond { + ClickStackNumberTileColorCondition::Unknown(v) => { + assert_eq!(v["operator"], "contains"); + assert_eq!(v["value"], "warn"); + } + other => panic!("expected unknown variant, got {other}"), + } + let expected: serde_json::Value = serde_json::from_str(json).unwrap(); + assert_eq!(serde_json::to_value(&cond).unwrap(), expected); +} + +#[test] +fn clickstack_tile_config_categorical_bar_builder_variant() { + // `displayType: "bar"` must dispatch to the categorical bar variant, not the + // stacked bar variant (whose discriminator is "stacked_bar"). + let json = r#"{ + "displayType": "bar", + "sourceId": "src-1", + "select": [{"aggFn": "count"}] + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackCategoricalBarChartConfig( + ClickStackCategoricalBarChartConfig::ClickStackCategoricalBarBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, "src-1"); + assert_eq!( + b.display_type, + ClickStackCategoricalBarBuilderChartConfigDisplaytype::Bar + ); + assert_eq!(b.select.len(), 1); + } + other => panic!("expected categorical bar builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_categorical_bar_raw_sql_variant() { + // The Raw SQL categorical bar (configType "sql") also dispatches to the + // categorical bar variant rather than the stacked bar Raw SQL variant. + let json = r#"{ + "displayType": "bar", + "configType": "sql", + "connectionId": "conn-1", + "sqlTemplate": "SELECT count() FROM t GROUP BY service" + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackCategoricalBarChartConfig( + ClickStackCategoricalBarChartConfig::ClickStackCategoricalBarRawSqlChartConfig(r), + ) => { + assert_eq!(r.connection_id, "conn-1"); + assert_eq!(r.sql_template, "SELECT count() FROM t GROUP BY service"); + assert_eq!( + r.display_type, + ClickStackCategoricalBarRawSqlChartConfigDisplaytype::Bar + ); + } + other => panic!("expected categorical bar raw sql variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_stacked_bar_not_categorical_bar() { + // The manual `displayType`-keyed dispatch routes "stacked_bar" to the + // stacked-bar variant, never to the structurally identical categorical + // bar variant (whose displayType is "bar"). + let json = r#"{ + "displayType": "stacked_bar", + "sourceId": "src-1", + "select": [{"aggFn": "count"}] + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + assert!( + !matches!( + cfg, + ClickStackTileConfig::ClickStackCategoricalBarChartConfig(_) + ), + "stacked_bar must not dispatch to the categorical bar variant, got {cfg}" + ); +} + +#[test] +fn clickstack_tile_config_event_patterns_variant() { + // `displayType: "event_patterns"` dispatches to the event-patterns variant. + let json = r#"{ + "displayType": "event_patterns", + "sourceId": "src-9", + "where": "level:error", + "whereLanguage": "lucene" + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackEventPatternsChartConfig(e) => { + assert_eq!(e.source_id, "src-9"); + assert_eq!( + e.display_type, + ClickStackEventPatternsChartConfigDisplaytype::Event_patterns + ); + assert_eq!(e.r#where.as_deref(), Some("level:error")); + assert_eq!( + e.where_language, + Some(ClickStackEventPatternsChartConfigWherelanguage::Lucene) + ); + } + other => panic!("expected event patterns variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_line_variant() { + // `displayType: "line"` must dispatch to the line variant with a typed + // display_type, not be swallowed as an Unknown displayType by another + // structurally-identical builder variant. + let json = r#"{ + "displayType": "line", + "sourceId": "src-1", + "select": [{"aggFn": "count"}] + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackLineChartConfig( + ClickStackLineChartConfig::ClickStackLineBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, "src-1"); + assert_eq!( + b.display_type, + ClickStackLineBuilderChartConfigDisplaytype::Line + ); + } + other => panic!("expected line builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_stacked_bar_builder_variant() { + // `displayType: "stacked_bar"` must dispatch to the (stacked) bar variant + // with a typed display_type. + let json = r#"{ + "displayType": "stacked_bar", + "sourceId": "src-1", + "select": [{"aggFn": "count"}] + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackBarChartConfig( + ClickStackBarChartConfig::ClickStackBarBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, "src-1"); + assert_eq!( + b.display_type, + ClickStackBarBuilderChartConfigDisplaytype::Stacked_bar + ); + } + other => panic!("expected stacked bar builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_table_variant() { + // `displayType: "table"` must dispatch to the table variant with a typed + // display_type (the legacy misdispatch parsed it as the first-listed + // variant with an Unknown display_type). + let json = r#"{ + "displayType": "table", + "sourceId": "src-1", + "select": [{"aggFn": "count"}] + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackTableChartConfig( + ClickStackTableChartConfig::ClickStackTableBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, "src-1"); + assert_eq!( + b.display_type, + ClickStackTableBuilderChartConfigDisplaytype::Table + ); + } + other => panic!("expected table builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_number_variant() { + // `displayType: "number"` must dispatch to the number variant with a typed + // display_type. + let json = r#"{ + "displayType": "number", + "sourceId": "src-1", + "select": [{"aggFn": "count"}] + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackNumberChartConfig( + ClickStackNumberChartConfig::ClickStackNumberBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, "src-1"); + assert_eq!( + b.display_type, + ClickStackNumberBuilderChartConfigDisplaytype::Number + ); + } + other => panic!("expected number builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_pie_variant() { + // `displayType: "pie"` must dispatch to the pie variant with a typed + // display_type. + let json = r#"{ + "displayType": "pie", + "sourceId": "src-1", + "select": [{"aggFn": "count"}] + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackPieChartConfig( + ClickStackPieChartConfig::ClickStackPieBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, "src-1"); + assert_eq!( + b.display_type, + ClickStackPieBuilderChartConfigDisplaytype::Pie + ); + } + other => panic!("expected pie builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_search_variant() { + // `displayType: "search"` must dispatch to the search variant with a typed + // display_type. + let json = r#"{ + "displayType": "search", + "sourceId": "src-1", + "select": "*", + "whereLanguage": "lucene" + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackSearchChartConfig(s) => { + assert_eq!(s.source_id, "src-1"); + assert_eq!(s.select, "*"); + assert_eq!( + s.display_type, + ClickStackSearchChartConfigDisplaytype::Search + ); + } + other => panic!("expected search variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_markdown_variant() { + // `displayType: "markdown"` must dispatch to the markdown variant with a + // typed display_type rather than being absorbed by an earlier variant. + let json = r#"{ + "displayType": "markdown", + "markdown": "hello world" + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackMarkdownChartConfig(m) => { + assert_eq!(m.markdown.as_deref(), Some("hello world")); + assert_eq!( + m.display_type, + ClickStackMarkdownChartConfigDisplaytype::Markdown + ); + } + other => panic!("expected markdown variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_heatmap_variant() { + // `displayType: "heatmap"` is the only discriminator that reaches the + // ClickStackHeatmapChartConfig arm; the heatmap-specific `select` shape with + // `valueExpression` is then parsed by that variant, not used to select it. + let json = r#"{ + "displayType": "heatmap", + "sourceId": "src-1", + "select": [{"valueExpression": "latency_ms"}] + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackHeatmapChartConfig( + ClickStackHeatmapChartConfig::ClickStackHeatmapDistributionChartConfig(h), + ) => { + assert_eq!(h.source_id, "src-1"); + assert_eq!( + h.display_type, + ClickStackHeatmapDistributionChartConfigDisplaytype::Heatmap + ); + assert_eq!(h.heatmap_mode, None); + assert_eq!(h.select.len(), 1); + assert_eq!(h.select[0].value_expression, "latency_ms"); + } + other => panic!("expected heatmap variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_unknown_display_type_round_trips() { + // An unrecognized `displayType` falls to the Unknown catch-all, which now + // stores the raw object and round-trips it faithfully. + let json = r#"{"displayType":"sankey","sourceId":"src-1"}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { + matches!(cfg, ClickStackTileConfig::Unknown(_)) + }); +} + +#[test] +fn clickstack_tile_config_response_line_novel_shape_dispatches_to_builder() { + // A known `displayType` carrying no `configType` dispatches to the builder + // variant: the novel member is ignored and the builder's all-`Option` + // response fields surface `None` instead of failing the response. + let json = r#"{"displayType":"line","somethingNew":true}"#; + let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfigResponse::ClickStackLineChartConfig( + ClickStackLineChartConfigResponse::ClickStackLineBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, None); + assert_eq!(b.select, None); + } + other => panic!("expected line builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_response_line_minimal_body_dispatches_to_builder() { + // The bare discriminator with no `configType` key at all: key absence is the + // builder discriminator, and every other builder response field is absent. + let json = r#"{"displayType":"line"}"#; + let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfigResponse::ClickStackLineChartConfig( + ClickStackLineChartConfigResponse::ClickStackLineBuilderChartConfig(b), + ) => { + assert_eq!( + b, + ClickStackLineBuilderChartConfigResponse { + display_type: Some(ClickStackLineBuilderChartConfigDisplaytype::Line), + ..ClickStackLineBuilderChartConfigResponse::default() + } + ); + } + other => panic!("expected line builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_response_non_string_config_type_dispatches_to_builder() { + // A non-string `configType` is deliberately conflated with an absent one: + // both dispatch to the builder variant rather than to Unknown. + let json = r#"{"displayType":"line","configType":123,"sourceId":"src-1"}"#; + let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfigResponse::ClickStackLineChartConfig( + ClickStackLineChartConfigResponse::ClickStackLineBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id.as_deref(), Some("src-1")); + } + other => panic!("expected line builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_line_unrecognized_config_type_falls_to_sub_union_unknown() { + // An unrecognized *string* `configType` reaches the line sub-union's + // Unknown(Value); it round-trips losslessly. + let json = r#"{"displayType":"line","configType":"future"}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { + matches!( + cfg, + ClickStackTileConfig::ClickStackLineChartConfig(ClickStackLineChartConfig::Unknown(_)) + ) + }); +} + +#[test] +fn clickstack_tile_config_raw_sql_body_without_config_type_falls_to_sub_union_unknown() { + // `configType` is spec-required on the Raw SQL configs, so a server that + // stops sending it must not silently retype the tile: the builder variant is + // total and would otherwise absorb the body and drop `connectionId` and + // `sqlTemplate`. The `unless` guard routes it to Unknown, which keeps the + // payload verbatim. + let json = r#"{"displayType":"line","connectionId":"conn-1","sqlTemplate":"SELECT 1"}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { + matches!( + cfg, + ClickStackTileConfig::ClickStackLineChartConfig(ClickStackLineChartConfig::Unknown(_)) + ) + }); + + // Either guard key on its own disqualifies the builder variant, and the + // guard is wired on every chart-config sub-union, not just the line one. + let json = r#"{"displayType":"number","connectionId":"conn-1"}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { + matches!( + cfg, + ClickStackTileConfig::ClickStackNumberChartConfig( + ClickStackNumberChartConfig::Unknown(_) + ) + ) + }); +} + +#[test] +fn clickstack_tile_config_changed_field_shape_falls_to_sub_union_unknown() { + // Field-level tolerance only covers a field the API stops sending. A field + // whose *shape* changes cannot deserialize into the dispatched variant, so + // the union hands the payload to its lossless Unknown catch-all rather than + // failing the whole dashboard response. + let builder = r#"{"displayType":"line","sourceId":123}"#; + assert_unknown_variant_round_trips(builder, |cfg: &ClickStackTileConfig| { + matches!( + cfg, + ClickStackTileConfig::ClickStackLineChartConfig(ClickStackLineChartConfig::Unknown(_)) + ) + }); + + let raw_sql = r#"{"displayType":"line","configType":"sql","sqlTemplate":123}"#; + assert_unknown_variant_round_trips(raw_sql, |cfg: &ClickStackTileConfig| { + matches!( + cfg, + ClickStackTileConfig::ClickStackLineChartConfig(ClickStackLineChartConfig::Unknown(_)) + ) + }); +} + +#[test] +fn clickstack_tile_config_line_raw_sql_variant() { + // The Raw SQL line config (configType "sql") dispatches to the line + // sub-union's Raw SQL variant, not its builder or Unknown variant. + let json = r#"{ + "displayType": "line", + "configType": "sql", + "connectionId": "conn-1", + "sqlTemplate": "SELECT count() FROM t GROUP BY toStartOfMinute(ts)" + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackLineChartConfig( + ClickStackLineChartConfig::ClickStackLineRawSqlChartConfig(r), + ) => { + assert_eq!(r.connection_id, "conn-1"); + assert_eq!( + r.sql_template, + "SELECT count() FROM t GROUP BY toStartOfMinute(ts)" + ); + assert_eq!( + r.display_type, + ClickStackLineRawSqlChartConfigDisplaytype::Line + ); + } + other => panic!("expected line raw sql variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_stacked_bar_raw_sql_variant() { + // The Raw SQL (stacked) bar config (configType "sql") dispatches to the bar + // sub-union's Raw SQL variant, not its builder or Unknown variant. + let json = r#"{ + "displayType": "stacked_bar", + "configType": "sql", + "connectionId": "conn-1", + "sqlTemplate": "SELECT count() FROM t GROUP BY service" + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackBarChartConfig( + ClickStackBarChartConfig::ClickStackBarRawSqlChartConfig(r), + ) => { + assert_eq!(r.connection_id, "conn-1"); + assert_eq!(r.sql_template, "SELECT count() FROM t GROUP BY service"); + assert_eq!( + r.display_type, + ClickStackBarRawSqlChartConfigDisplaytype::Stacked_bar + ); + } + other => panic!("expected stacked bar raw sql variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_table_raw_sql_variant() { + // The Raw SQL table config (configType "sql") dispatches to the table + // sub-union's Raw SQL variant, not its builder or Unknown variant. + let json = r#"{ + "displayType": "table", + "configType": "sql", + "connectionId": "conn-1", + "sqlTemplate": "SELECT service, count() FROM t GROUP BY service" + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackTableChartConfig( + ClickStackTableChartConfig::ClickStackTableRawSqlChartConfig(r), + ) => { + assert_eq!(r.connection_id, "conn-1"); + assert_eq!( + r.sql_template, + "SELECT service, count() FROM t GROUP BY service" + ); + assert_eq!( + r.display_type, + ClickStackTableRawSqlChartConfigDisplaytype::Table + ); + } + other => panic!("expected table raw sql variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_number_raw_sql_variant() { + // The Raw SQL number config (configType "sql") dispatches to the number + // sub-union's Raw SQL variant, not its builder or Unknown variant. + let json = r#"{ + "displayType": "number", + "configType": "sql", + "connectionId": "conn-1", + "sqlTemplate": "SELECT count() FROM t" + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackNumberChartConfig( + ClickStackNumberChartConfig::ClickStackNumberRawSqlChartConfig(r), + ) => { + assert_eq!(r.connection_id, "conn-1"); + assert_eq!(r.sql_template, "SELECT count() FROM t"); + assert_eq!( + r.display_type, + ClickStackNumberRawSqlChartConfigDisplaytype::Number + ); + } + other => panic!("expected number raw sql variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_pie_raw_sql_variant() { + // The Raw SQL pie config (configType "sql") dispatches to the pie + // sub-union's Raw SQL variant, not its builder or Unknown variant. + let json = r#"{ + "displayType": "pie", + "configType": "sql", + "connectionId": "conn-1", + "sqlTemplate": "SELECT service, count() FROM t GROUP BY service" + }"#; + let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfig::ClickStackPieChartConfig( + ClickStackPieChartConfig::ClickStackPieRawSqlChartConfig(r), + ) => { + assert_eq!(r.connection_id, "conn-1"); + assert_eq!( + r.sql_template, + "SELECT service, count() FROM t GROUP BY service" + ); + assert_eq!( + r.display_type, + ClickStackPieRawSqlChartConfigDisplaytype::Pie + ); + } + other => panic!("expected pie raw sql variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_response_categorical_bar_novel_shape_dispatches_to_builder() { + // A "bar" body with no `configType` dispatches to the categorical bar builder + // variant, whose all-`Option` response fields surface `None` rather than + // failing the response. + let json = r#"{"displayType":"bar","somethingNew":true}"#; + let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfigResponse::ClickStackCategoricalBarChartConfig( + ClickStackCategoricalBarChartConfigResponse::ClickStackCategoricalBarBuilderChartConfig( + b, + ), + ) => { + assert_eq!(b.source_id, None); + assert_eq!(b.select, None); + } + other => panic!("expected categorical bar builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_categorical_bar_unrecognized_config_type_falls_to_sub_union_unknown() { + // An unrecognized *string* `configType` reaches the categorical bar + // sub-union's Unknown(Value); it round-trips losslessly. + let json = r#"{"displayType":"bar","configType":"future"}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { + matches!( + cfg, + ClickStackTileConfig::ClickStackCategoricalBarChartConfig( + ClickStackCategoricalBarChartConfig::Unknown(_) + ) + ) + }); +} + +#[test] +fn clickstack_tile_config_response_stacked_bar_novel_shape_dispatches_to_builder() { + // A "stacked_bar" body with no `configType` dispatches to the bar builder + // variant, whose all-`Option` response fields surface `None` rather than + // failing the response. + let json = r#"{"displayType":"stacked_bar","somethingNew":true}"#; + let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfigResponse::ClickStackBarChartConfig( + ClickStackBarChartConfigResponse::ClickStackBarBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, None); + assert_eq!(b.select, None); + } + other => panic!("expected bar builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_stacked_bar_unrecognized_config_type_falls_to_sub_union_unknown() { + // An unrecognized *string* `configType` reaches the bar sub-union's + // Unknown(Value); it round-trips losslessly. + let json = r#"{"displayType":"stacked_bar","configType":"future"}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { + matches!( + cfg, + ClickStackTileConfig::ClickStackBarChartConfig(ClickStackBarChartConfig::Unknown(_)) + ) + }); +} + +#[test] +fn clickstack_tile_config_response_table_novel_shape_dispatches_to_builder() { + // A "table" body with no `configType` dispatches to the table builder + // variant, whose all-`Option` response fields surface `None` rather than + // failing the response. + let json = r#"{"displayType":"table","somethingNew":true}"#; + let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfigResponse::ClickStackTableChartConfig( + ClickStackTableChartConfigResponse::ClickStackTableBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, None); + assert_eq!(b.select, None); + } + other => panic!("expected table builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_table_unrecognized_config_type_falls_to_sub_union_unknown() { + // An unrecognized *string* `configType` reaches the table sub-union's + // Unknown(Value); it round-trips losslessly. + let json = r#"{"displayType":"table","configType":"future"}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { + matches!( + cfg, + ClickStackTileConfig::ClickStackTableChartConfig(ClickStackTableChartConfig::Unknown( + _ + )) + ) + }); +} + +#[test] +fn clickstack_tile_config_response_number_novel_shape_dispatches_to_builder() { + // A "number" body with no `configType` dispatches to the number builder + // variant, whose all-`Option` response fields surface `None` rather than + // failing the response. + let json = r#"{"displayType":"number","somethingNew":true}"#; + let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfigResponse::ClickStackNumberChartConfig( + ClickStackNumberChartConfigResponse::ClickStackNumberBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, None); + assert_eq!(b.select, None); + } + other => panic!("expected number builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_number_unrecognized_config_type_falls_to_sub_union_unknown() { + // An unrecognized *string* `configType` reaches the number sub-union's + // Unknown(Value); it round-trips losslessly. + let json = r#"{"displayType":"number","configType":"future"}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { + matches!( + cfg, + ClickStackTileConfig::ClickStackNumberChartConfig( + ClickStackNumberChartConfig::Unknown(_) + ) + ) + }); +} + +#[test] +fn clickstack_tile_config_response_pie_novel_shape_dispatches_to_builder() { + // A "pie" body with no `configType` dispatches to the pie builder + // variant, whose all-`Option` response fields surface `None` rather than + // failing the response. + let json = r#"{"displayType":"pie","somethingNew":true}"#; + let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); + match cfg { + ClickStackTileConfigResponse::ClickStackPieChartConfig( + ClickStackPieChartConfigResponse::ClickStackPieBuilderChartConfig(b), + ) => { + assert_eq!(b.source_id, None); + assert_eq!(b.select, None); + } + other => panic!("expected pie builder variant, got {other}"), + } +} + +#[test] +fn clickstack_tile_config_pie_unrecognized_config_type_falls_to_sub_union_unknown() { + // An unrecognized *string* `configType` reaches the pie sub-union's + // Unknown(Value); it round-trips losslessly. + let json = r#"{"displayType":"pie","configType":"future"}"#; + assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { + matches!( + cfg, + ClickStackTileConfig::ClickStackPieChartConfig(ClickStackPieChartConfig::Unknown(_)) + ) + }); +} + +#[test] +fn clickstack_chart_color_known_and_unknown_round_trip() { + // A known palette token maps to its typed variant and back. + let known: ClickStackChartColor = serde_json::from_str("\"chart-light-blue\"").unwrap(); + assert_eq!(known, ClickStackChartColor::Chart_light_blue); + assert_eq!(known.to_string(), "chart-light-blue"); + assert_eq!(serde_json::to_value(&known).unwrap(), "chart-light-blue"); + + // An unrecognized token round-trips through the Unknown(String) catch-all. + let unknown: ClickStackChartColor = serde_json::from_str("\"chart-teal\"").unwrap(); + assert_eq!( + unknown, + ClickStackChartColor::Unknown("chart-teal".to_string()) + ); + assert_eq!(unknown.to_string(), "chart-teal"); + assert_eq!(serde_json::to_value(&unknown).unwrap(), "chart-teal"); +} + +#[test] +fn clickstack_background_chart_round_trip() { + let json = r#"{"type": "area", "color": "chart-blue"}"#; + let bg: ClickStackBackgroundChart = serde_json::from_str(json).unwrap(); + assert_eq!(bg.r#type, ClickStackBackgroundChartType::Area); + assert_eq!(bg.color, Some(ClickStackChartColor::Chart_blue)); + let v = serde_json::to_value(&bg).unwrap(); + assert_eq!(v["type"], "area"); + assert_eq!(v["color"], "chart-blue"); + + // color is optional and dropped when absent. + let json = r#"{"type": "line"}"#; + let bg: ClickStackBackgroundChart = serde_json::from_str(json).unwrap(); + assert_eq!(bg.r#type, ClickStackBackgroundChartType::Line); + assert_eq!(bg.color, None); + let v = serde_json::to_value(&bg).unwrap(); + assert!(v.get("color").is_none()); +} + +#[test] +fn clickstack_number_builder_chart_config_color_fields_round_trip() { + let json = r#"{ + "displayType": "number", + "sourceId": "src-1", + "select": [{"aggFn": "count"}], + "color": "chart-blue", + "colorRules": [ + {"operator": "gt", "value": 100, "color": "chart-red"} + ], + "backgroundChart": {"type": "line", "color": "chart-green"} + }"#; + let cfg: ClickStackNumberBuilderChartConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.color, Some(ClickStackChartColor::Chart_blue)); + let rules = cfg.color_rules.as_ref().unwrap(); + assert_eq!(rules.len(), 1); + match &rules[0] { + ClickStackNumberTileColorCondition::ClickStackNumericColorCondition(c) => { + assert_eq!(c.operator, ClickStackNumericColorConditionOperator::Gt); + } + other => panic!("expected numeric rule, got {other}"), + } + let bg = cfg.background_chart.as_ref().unwrap(); + assert_eq!(bg.r#type, ClickStackBackgroundChartType::Line); + + let v = serde_json::to_value(&cfg).unwrap(); + assert_eq!(v["color"], "chart-blue"); + assert_eq!(v["colorRules"][0]["operator"], "gt"); + assert_eq!(v["backgroundChart"]["type"], "line"); + + // The three new fields are optional and dropped when absent. + let v = serde_json::to_value(ClickStackNumberBuilderChartConfig::default()).unwrap(); + assert!(v.get("color").is_none()); + assert!(v.get("colorRules").is_none()); + assert!(v.get("backgroundChart").is_none()); +} + +#[test] +fn clickstack_number_raw_sql_chart_config_color_round_trip() { + let json = r#"{ + "configType": "sql", + "connectionId": "conn-1", + "displayType": "number", + "sqlTemplate": "SELECT count() FROM t", + "color": "chart-purple" + }"#; + let cfg: ClickStackNumberRawSqlChartConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.color, Some(ClickStackChartColor::Chart_purple)); + let v = serde_json::to_value(&cfg).unwrap(); + assert_eq!(v["color"], "chart-purple"); + + let v = serde_json::to_value(ClickStackNumberRawSqlChartConfig::default()).unwrap(); + assert!(v.get("color").is_none()); +} + +#[test] +fn deserialize_clickstack_connection_with_null_prefix() { + let json = r#"{ + "id": "507f1f77bcf86cd799439012", + "name": "Production ClickHouse", + "host": "https://clickhouse.example.com:8443", + "username": "default", + "hyperdxSettingPrefix": null, + "isPrometheusEndpoint": false, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-06-15T10:30:00.000Z" + }"#; + let conn: ClickStackConnection = serde_json::from_str(json).unwrap(); + assert_eq!(conn.id.as_deref(), Some("507f1f77bcf86cd799439012")); + assert_eq!(conn.name.as_deref(), Some("Production ClickHouse")); + assert_eq!( + conn.host.as_deref(), + Some("https://clickhouse.example.com:8443") + ); + assert_eq!(conn.username.as_deref(), Some("default")); + assert_eq!(conn.hyperdx_setting_prefix, None); + assert_eq!(conn.is_prometheus_endpoint, Some(false)); + assert!(conn.created_at.is_some()); + assert!(conn.updated_at.is_some()); +} + +#[test] +fn serialize_clickstack_create_connection_request_omits_none() { + let req = ClickStackCreateConnectionRequest { + name: "Production ClickHouse".to_string(), + host: "https://clickhouse.example.com:8443".to_string(), + username: "default".to_string(), + password: Some("my-secret-password".to_string()), + ..Default::default() + }; + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v["name"], "Production ClickHouse"); + assert_eq!(v["host"], "https://clickhouse.example.com:8443"); + assert_eq!(v["username"], "default"); + assert_eq!(v["password"], "my-secret-password"); + assert!( + v.get("hyperdxSettingPrefix").is_none(), + "hyperdxSettingPrefix must be omitted when None" + ); + assert!( + v.get("isPrometheusEndpoint").is_none(), + "isPrometheusEndpoint must be omitted when None" + ); +} + +/// A ClickHouse-integration permission built from every property of +/// `ClickStackClickHouseCASLPermission` and its row-policy conditions. +fn clickhouse_casl_permission_json() -> serde_json::Value { + serde_json::json!({ + "action": "read", + "subject": "Source", + "fields": ["Body", "ServiceName"], + "inverted": false, + "integration": "clickhouse", + "conditions": { + "connection": "507f1f77bcf86cd799439011", + "from": {"databaseName": "default", "tableName": "otel_logs"}, + "where": "ServiceName = 'checkout'", + "whereLanguage": "sql" + } + }) +} + +/// A MongoDB-integration permission built from every property of +/// `ClickStackMongoDBCASLPermission`, with both condition value shapes. +fn mongodb_casl_permission_json() -> serde_json::Value { + serde_json::json!({ + "action": "manage", + "subject": "Dashboard", + "fields": ["name"], + "inverted": true, + "integration": "mongodb", + "conditions": { + "team": "team-1", + "name": {"$regex": "^prod-", "$options": "i"} + } + }) +} + +#[test] +fn clickstack_casl_permission_request_routes_each_shape_to_its_variant() { + let permission: ClickStackCASLPermission = + serde_json::from_value(clickhouse_casl_permission_json()).unwrap(); + let ClickStackCASLPermission::ClickStackClickHouseCASLPermission(clickhouse) = &permission + else { + panic!("expected the ClickHouse variant, got {permission:?}"); + }; + assert_eq!(clickhouse.action, ClickStackCASLPermissionAction::Read); + assert_eq!(clickhouse.subject, ClickStackCASLPermissionSubject::Source); + assert_eq!( + clickhouse.integration, + ClickStackClickHouseCASLPermissionIntegration::Clickhouse + ); + assert_eq!( + clickhouse.fields.as_deref(), + Some(&["Body".to_string(), "ServiceName".to_string()][..]) + ); + assert_eq!(clickhouse.inverted, Some(false)); + let conditions = clickhouse.conditions.as_ref().unwrap(); + assert_eq!(conditions.connection, "507f1f77bcf86cd799439011"); + assert_eq!(conditions.from.database_name, "default"); + assert_eq!(conditions.from.table_name, "otel_logs"); + assert_eq!(conditions.r#where, "ServiceName = 'checkout'"); + assert_eq!( + conditions.where_language, + ClickStackClickHouseCASLPermissionConditionsWherelanguage::Sql + ); + + let permission: ClickStackCASLPermission = + serde_json::from_value(mongodb_casl_permission_json()).unwrap(); + let ClickStackCASLPermission::ClickStackMongoDBCASLPermission(mongodb) = &permission else { + panic!("expected the MongoDB variant, got {permission:?}"); + }; + assert_eq!(mongodb.action, ClickStackCASLPermissionAction::Manage); + assert_eq!(mongodb.subject, ClickStackCASLPermissionSubject::Dashboard); + assert_eq!( + mongodb.integration, + ClickStackMongoDBCASLPermissionIntegration::Mongodb + ); + assert_eq!(mongodb.inverted, Some(true)); + let conditions = mongodb.conditions.as_ref().unwrap(); + assert_eq!( + conditions["team"], + ClickStackMongoDBCASLPermissionConditionValue::String("team-1".to_string()) + ); + assert_eq!( + conditions["name"], + ClickStackMongoDBCASLPermissionConditionValue::Regex( + ClickStackMongoDBCASLPermissionRegexCondition { + regex: "^prod-".to_string(), + options: Some("i".to_string()), + } + ) + ); +} + +#[test] +fn clickstack_casl_permission_response_routes_each_shape_to_its_variant() { + let permission: ClickStackCASLPermissionResponse = + serde_json::from_value(clickhouse_casl_permission_json()).unwrap(); + let ClickStackCASLPermissionResponse::ClickStackClickHouseCASLPermission(clickhouse) = + &permission + else { + panic!("expected the ClickHouse variant, got {permission:?}"); + }; + assert_eq!( + clickhouse.action, + Some(ClickStackCASLPermissionAction::Read) + ); + let conditions = clickhouse.conditions.as_ref().unwrap(); + assert_eq!( + conditions.connection.as_deref(), + Some("507f1f77bcf86cd799439011") + ); + assert_eq!( + conditions.from.as_ref().unwrap().table_name.as_deref(), + Some("otel_logs") + ); + assert_eq!( + conditions.where_language, + Some(ClickStackClickHouseCASLPermissionConditionsWherelanguage::Sql) + ); + + let permission: ClickStackCASLPermissionResponse = + serde_json::from_value(mongodb_casl_permission_json()).unwrap(); + let ClickStackCASLPermissionResponse::ClickStackMongoDBCASLPermission(mongodb) = &permission + else { + panic!("expected the MongoDB variant, got {permission:?}"); + }; + assert_eq!( + mongodb.integration, + Some(ClickStackMongoDBCASLPermissionIntegration::Mongodb) + ); + // Response condition values stay raw JSON, whatever shape they take. + let conditions = mongodb.conditions.as_ref().unwrap(); + assert_eq!(conditions["team"], "team-1"); + assert_eq!(conditions["name"]["$regex"], "^prod-"); +} + +#[test] +fn clickstack_casl_permission_dispatch_ignores_the_payload_shape() { + // Dispatch reads `integration` alone. Every response arm is all-`Option`, + // so shape-based `untagged` matching would accept any object in the first + // arm; a ClickHouse row policy must not become a MongoDB permission, and a + // MongoDB query must not become a ClickHouse one. + let mut clickhouse_conditions_as_mongodb = clickhouse_casl_permission_json(); + clickhouse_conditions_as_mongodb["integration"] = "mongodb".into(); + let permission: ClickStackCASLPermissionResponse = + serde_json::from_value(clickhouse_conditions_as_mongodb.clone()).unwrap(); + assert!( + matches!( + permission, + ClickStackCASLPermissionResponse::ClickStackMongoDBCASLPermission(_) + ), + "{permission:?}" + ); + + let mut mongodb_conditions_as_clickhouse = mongodb_casl_permission_json(); + mongodb_conditions_as_clickhouse["integration"] = "clickhouse".into(); + // `conditions.name` is an object where the ClickHouse shape has no such + // key, and the ClickHouse `from` is absent: the request variant cannot be + // built, so the request union keeps the payload verbatim in `Unknown`. + let permission: ClickStackCASLPermission = + serde_json::from_value(mongodb_conditions_as_clickhouse.clone()).unwrap(); + assert_eq!( + permission, + ClickStackCASLPermission::Unknown(mongodb_conditions_as_clickhouse) + ); + + // No integration, or one this crate does not know, never guesses a variant. + for integration in [None, Some("postgres")] { + let mut payload = mongodb_casl_permission_json(); + match integration { + Some(integration) => payload["integration"] = integration.into(), + None => { + payload.as_object_mut().unwrap().remove("integration"); + } + } + let request: ClickStackCASLPermission = serde_json::from_value(payload.clone()).unwrap(); + assert_eq!(request, ClickStackCASLPermission::Unknown(payload.clone())); + let response: ClickStackCASLPermissionResponse = + serde_json::from_value(payload.clone()).unwrap(); + assert_eq!( + response, + ClickStackCASLPermissionResponse::Unknown(payload.clone()) + ); + assert_eq!(serde_json::to_value(&response).unwrap(), payload); + } +} + +#[test] +fn clickstack_casl_permission_round_trips_in_both_positions() { + for payload in [ + clickhouse_casl_permission_json(), + mongodb_casl_permission_json(), + ] { + let request: ClickStackCASLPermission = serde_json::from_value(payload.clone()).unwrap(); + assert!(!matches!(request, ClickStackCASLPermission::Unknown(_))); + assert_eq!(serde_json::to_value(&request).unwrap(), payload); + let back: ClickStackCASLPermission = + serde_json::from_value(serde_json::to_value(&request).unwrap()).unwrap(); + assert_eq!(back, request); + + let response: ClickStackCASLPermissionResponse = + serde_json::from_value(payload.clone()).unwrap(); + assert!(!matches!( + response, + ClickStackCASLPermissionResponse::Unknown(_) + )); + assert_eq!(serde_json::to_value(&response).unwrap(), payload); + } +} + +#[test] +fn clickstack_casl_permission_variant_defaults_round_trip_to_their_own_variant() { + // Each variant's `integration` defaults to its own wire value, so a + // default-built request variant still carries its discriminator. + let clickhouse = ClickStackCASLPermission::ClickStackClickHouseCASLPermission( + ClickStackClickHouseCASLPermission::default(), + ); + let mongodb = ClickStackCASLPermission::ClickStackMongoDBCASLPermission( + ClickStackMongoDBCASLPermission::default(), + ); + for permission in [clickhouse, mongodb] { + let json = serde_json::to_value(&permission).unwrap(); + assert_eq!( + serde_json::from_value::(json).unwrap(), + permission + ); + } +} + +#[test] +fn clickstack_casl_permission_try_from_response_converts_every_integration() { + for payload in [ + clickhouse_casl_permission_json(), + mongodb_casl_permission_json(), + ] { + let response: ClickStackCASLPermissionResponse = + serde_json::from_value(payload.clone()).unwrap(); + let request = ClickStackCASLPermission::try_from(response).unwrap(); + assert!( + !matches!(request, ClickStackCASLPermission::Unknown(_)), + "{request:?}" + ); + assert_eq!(serde_json::to_value(&request).unwrap(), payload); + } + + // The variant conversions round-trip on their own too. + let response: ClickStackClickHouseCASLPermissionResponse = + serde_json::from_value(clickhouse_casl_permission_json()).unwrap(); + let request = ClickStackClickHouseCASLPermission::try_from(response).unwrap(); + assert_eq!( + request, + serde_json::from_value::( + clickhouse_casl_permission_json() + ) + .unwrap() + ); + let response: ClickStackMongoDBCASLPermissionResponse = + serde_json::from_value(mongodb_casl_permission_json()).unwrap(); + let request = ClickStackMongoDBCASLPermission::try_from(response).unwrap(); + assert_eq!( + request, + serde_json::from_value::(mongodb_casl_permission_json()) + .unwrap() + ); +} + +#[test] +fn clickstack_casl_permission_try_from_response_names_every_missing_required_field() { + for (json, expected) in [ + ( + r#"{"integration":"clickhouse"}"#, + &["action", "subject"][..], + ), + ( + r#"{"integration":"mongodb","action":"read"}"#, + &["subject"][..], + ), + // Nested ClickHouse conditions name their own wire fields, unprefixed. + ( + r#"{"integration":"clickhouse","action":"read","subject":"Source","conditions":{}}"#, + &["connection", "from", "where", "whereLanguage"][..], + ), + ( + r#"{"integration":"clickhouse","action":"read","subject":"Source","conditions": + {"connection":"c","where":"1","whereLanguage":"sql","from":{"databaseName":"d"}}}"#, + &["tableName"][..], + ), + ] { + let response: ClickStackCASLPermissionResponse = serde_json::from_str(json).unwrap(); + assert!( + !matches!(response, ClickStackCASLPermissionResponse::Unknown(_)), + "{json}" + ); + let error = ClickStackCASLPermission::try_from(response).unwrap_err(); + assert_eq!(error.fields(), expected, "{json}"); + } + + // The variant conversions also require the discriminator itself. + let error = ClickStackClickHouseCASLPermission::try_from( + ClickStackClickHouseCASLPermissionResponse::default(), + ) + .unwrap_err(); + assert_eq!(error.fields(), ["action", "integration", "subject"]); + let error = ClickStackMongoDBCASLPermission::try_from( + ClickStackMongoDBCASLPermissionResponse::default(), + ) + .unwrap_err(); + assert_eq!(error.fields(), ["action", "integration", "subject"]); + + // An unmodelled integration passes through losslessly. + let json = r#"{"integration":"postgres","action":"read"}"#; + let response: ClickStackCASLPermissionResponse = serde_json::from_str(json).unwrap(); + let request = ClickStackCASLPermission::try_from(response).unwrap(); + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::from_str::(json).unwrap() + ); +} + +#[test] +fn clickstack_mongodb_casl_conditions_without_a_request_shape_are_never_dropped() { + // A condition value that is neither a string nor a `$regex` object. + let mut payload = mongodb_casl_permission_json(); + payload["conditions"]["count"] = serde_json::json!({"$gt": 3}); + + // The variant conversion names `conditions` instead of dropping the value, + // which would widen the permission. + let response: ClickStackMongoDBCASLPermissionResponse = + serde_json::from_value(payload.clone()).unwrap(); + let error = ClickStackMongoDBCASLPermission::try_from(response).unwrap_err(); + assert_eq!(error.fields(), ["conditions"]); + + // The union conversion writes the permission back verbatim, exactly as the + // request union deserializes the same body. + let response: ClickStackCASLPermissionResponse = + serde_json::from_value(payload.clone()).unwrap(); + let request = ClickStackCASLPermission::try_from(response).unwrap(); + assert_eq!(request, ClickStackCASLPermission::Unknown(payload.clone())); + assert_eq!( + serde_json::from_value::(payload.clone()).unwrap(), + request + ); + + // A missing required field is still reported, alongside `conditions`. + payload.as_object_mut().unwrap().remove("action"); + let response: ClickStackCASLPermissionResponse = serde_json::from_value(payload).unwrap(); + let error = ClickStackCASLPermission::try_from(response).unwrap_err(); + assert_eq!(error.fields(), ["action", "conditions"]); +} + +#[test] +fn clickstack_casl_permission_variants_stay_strict_on_the_request_side() { + // Each required field of the request variants is `T`: dropping one fails + // the variant (and so routes the union payload to `Unknown`). + for (payload, required) in [ + (clickhouse_casl_permission_json(), "action"), + (clickhouse_casl_permission_json(), "subject"), + (clickhouse_casl_permission_json(), "integration"), + (mongodb_casl_permission_json(), "action"), + (mongodb_casl_permission_json(), "subject"), + (mongodb_casl_permission_json(), "integration"), + ] { + let mut payload = payload; + payload.as_object_mut().unwrap().remove(required); + let integration = payload.get("integration").cloned(); + let accepted = if integration.is_none() { + serde_json::from_value::(payload.clone()).is_ok() + || serde_json::from_value::(payload.clone()) + .is_ok() + } else if integration == Some("clickhouse".into()) { + serde_json::from_value::(payload.clone()).is_ok() + } else { + serde_json::from_value::(payload.clone()).is_ok() + }; + assert!(!accepted, "accepted a permission without `{required}`"); + assert!(matches!( + serde_json::from_value::(payload).unwrap(), + ClickStackCASLPermission::Unknown(_) + )); + } + for required in ["connection", "from", "where", "whereLanguage"] { + let mut conditions = clickhouse_casl_permission_json()["conditions"].clone(); + conditions.as_object_mut().unwrap().remove(required); + assert!( + serde_json::from_value::(conditions) + .is_err(), + "accepted ClickHouse conditions without `{required}`" + ); + } + for required in ["databaseName", "tableName"] { + let mut from = clickhouse_casl_permission_json()["conditions"]["from"].clone(); + from.as_object_mut().unwrap().remove(required); + assert!( + serde_json::from_value::(from) + .is_err(), + "accepted a ClickHouse `from` without `{required}`" + ); + } + // A MongoDB condition value is a string or a `$regex` object, nothing else. + for value in [ + serde_json::json!({"$options": "i"}), + serde_json::json!(3), + serde_json::json!(["a"]), + ] { + assert!( + serde_json::from_value::( + serde_json::json!({ "name": value }) + ) + .is_err(), + "accepted MongoDB condition value {value}" + ); + } +} + +#[test] +fn clickstack_casl_permission_responses_tolerate_missing_and_null_fields() { + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ClickStackClickHouseCASLPermissionResponse::default() + ); + assert_eq!( + serde_json::from_str::( + r#"{"action":null,"subject":null,"fields":null,"inverted":null,"integration":null,"conditions":null}"# + ) + .unwrap(), + ClickStackClickHouseCASLPermissionResponse::default() + ); + assert_eq!( + serde_json::from_str::( + r#"{"connection":null,"from":null,"where":null,"whereLanguage":null}"# + ) + .unwrap(), + ClickStackClickHouseCASLPermissionConditionsResponse::default() + ); + assert_eq!( + serde_json::from_str::( + r#"{"databaseName":null,"tableName":null}"# + ) + .unwrap(), + ClickStackClickHouseCASLPermissionConditionsFromResponse::default() + ); + assert_eq!( + serde_json::from_str::( + r#"{"action":null,"subject":null,"fields":null,"inverted":null,"integration":null,"conditions":null}"# + ) + .unwrap(), + ClickStackMongoDBCASLPermissionResponse::default() + ); + + // A dispatched permission with only its discriminator keeps that variant, + // and serializes without inventing the absent fields. + let permission: ClickStackCASLPermissionResponse = + serde_json::from_str(r#"{"integration":"clickhouse","action":null}"#).unwrap(); + assert_eq!( + permission, + ClickStackCASLPermissionResponse::ClickStackClickHouseCASLPermission( + ClickStackClickHouseCASLPermissionResponse { + integration: Some(ClickStackClickHouseCASLPermissionIntegration::Clickhouse), + ..Default::default() + } + ) + ); + assert_eq!( + serde_json::to_value(&permission).unwrap(), + serde_json::json!({"integration": "clickhouse"}) + ); +} + +#[test] +fn deserialize_clickstack_role_with_both_permission_kinds() { + let json = serde_json::json!({ + "id": "role-1", + "name": "Deploy Bot", + "description": "Manages dashboards via Terraform", + "isPredefined": false, + "permissions": [mongodb_casl_permission_json(), clickhouse_casl_permission_json()] + }); + let role: ClickStackRole = serde_json::from_value(json.clone()).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)); + let permissions = role.permissions.as_deref().expect("permissions present"); + assert!(matches!( + permissions, + [ + ClickStackCASLPermissionResponse::ClickStackMongoDBCASLPermission(_), + ClickStackCASLPermissionResponse::ClickStackClickHouseCASLPermission(_), + ] + )); + + // Optional role fields are omitted when absent, and the role round-trips. + let sparse = ClickStackRole { + id: Some("role-1".to_string()), + ..Default::default() + }; + let v = serde_json::to_value(&sparse).unwrap(); + assert!(v.get("description").is_none()); + assert!(v.get("createdAt").is_none()); + assert!(v.get("permissions").is_none()); + assert_eq!(serde_json::to_value(&role).unwrap(), json); + let back: ClickStackRole = serde_json::from_value(json).unwrap(); + assert_eq!(back, role); +} + +#[test] +fn serialize_clickstack_casl_permission_omits_none() { + let perm = ClickStackCASLPermission::ClickStackMongoDBCASLPermission( + ClickStackMongoDBCASLPermission { + action: ClickStackCASLPermissionAction::Read, + subject: ClickStackCASLPermissionSubject::Dashboard, + ..Default::default() + }, + ); + assert_eq!( + serde_json::to_value(&perm).unwrap(), + serde_json::json!({"action": "read", "subject": "Dashboard", "integration": "mongodb"}) + ); + let perm = ClickStackCASLPermission::ClickStackClickHouseCASLPermission( + ClickStackClickHouseCASLPermission { + action: ClickStackCASLPermissionAction::Read, + subject: ClickStackCASLPermissionSubject::All, + ..Default::default() + }, + ); + assert_eq!( + serde_json::to_value(&perm).unwrap(), + serde_json::json!({"action": "read", "subject": "all", "integration": "clickhouse"}) + ); +} + +#[test] +fn serialize_clickstack_create_role_request_omits_none() { + let req = ClickStackCreateRoleRequest { + name: "Deploy Bot".to_string(), + permissions: vec![serde_json::from_value(mongodb_casl_permission_json()).unwrap()], + ..Default::default() + }; + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v["name"], "Deploy Bot"); + assert_eq!(v["permissions"][0], mongodb_casl_permission_json()); + assert!(v.get("description").is_none()); +} + +#[test] +fn serialize_clickstack_update_role_request_omits_none() { + let req = ClickStackUpdateRoleRequest { + permissions: vec![serde_json::from_value(clickhouse_casl_permission_json()).unwrap()], + ..Default::default() + }; + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v["permissions"][0], clickhouse_casl_permission_json()); + assert!(v.get("name").is_none()); + assert!(v.get("description").is_none()); +} + +#[test] +fn deserialize_clickstack_saved_search_full_round_trip() { + let json = r#"{ + "id": "507f1f77bcf86cd799439011", + "name": "Production Errors", + "sourceId": "507f1f77bcf86cd799439012", + "select": "Timestamp, ServiceName, Body", + "where": "SeverityText:ERROR", + "whereLanguage": "lucene", + "orderBy": "Timestamp DESC", + "tags": ["production", "errors"], + "filters": [ + {"type": "sql", "condition": "ServiceName IN ('checkout', 'payments')"} + ], + "teamId": "507f1f77bcf86cd799439013", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-06-15T10:30:00.000Z" + }"#; + let search: ClickStackSavedSearch = serde_json::from_str(json).unwrap(); + assert_eq!(search.id.as_deref(), Some("507f1f77bcf86cd799439011")); + assert_eq!(search.name.as_deref(), Some("Production Errors")); + assert_eq!( + search.source_id.as_deref(), + Some("507f1f77bcf86cd799439012") + ); + assert_eq!( + search.where_language, + Some(ClickStackSavedSearchWherelanguage::Lucene) + ); + let filters = search.filters.clone().unwrap(); + assert_eq!(filters.len(), 1); + assert_eq!( + filters[0].r#type, + Some(ClickStackSavedSearchFilterType::Sql) + ); + assert_eq!( + filters[0].condition.as_deref(), + Some("ServiceName IN ('checkout', 'payments')") + ); + assert_eq!(search.team_id, Some("507f1f77bcf86cd799439013".to_string())); + + let v = serde_json::to_value(&search).unwrap(); + assert_eq!(v["whereLanguage"], "lucene"); + assert_eq!(v["filters"][0]["type"], "sql"); + assert_eq!(v["orderBy"], "Timestamp DESC"); + assert_eq!(v["tags"][0], "production"); + + let round: ClickStackSavedSearch = serde_json::from_value(v).unwrap(); + assert_eq!(round, search); +} + +#[test] +fn serialize_clickstack_saved_search_input_minimal_omits_optionals() { + let input = ClickStackSavedSearchInput { + name: "Production Errors".to_string(), + source_id: "507f1f77bcf86cd799439012".to_string(), + ..Default::default() + }; + let v = serde_json::to_value(&input).unwrap(); + assert_eq!(v["name"], "Production Errors"); + assert_eq!(v["sourceId"], "507f1f77bcf86cd799439012"); + assert!(v.get("select").is_none()); + assert!(v.get("where").is_none()); + assert!(v.get("whereLanguage").is_none()); + assert!(v.get("orderBy").is_none()); + assert!(v.get("tags").is_none()); + assert!(v.get("filters").is_none()); +} + +#[test] +fn clickstack_webhook_input_headers_and_query_params_round_trip() { + let json = r#"{ + "name": "Production Alerts", + "service": "incidentio", + "url": "https://api.incident.io/v2/alert_events/http/xyz", + "description": "Sends critical alerts", + "body": "{\"alert\": \"{{title}}\"}", + "headers": {"Authorization": "Bearer token", "X-Custom": "value"}, + "queryParams": {"source": "clickstack", "env": "prod"} + }"#; + let input: ClickStackWebhookInput = serde_json::from_str(json).unwrap(); + assert_eq!(input.name, "Production Alerts"); + assert_eq!(input.service, ClickStackWebhookInputService::Incidentio); + assert_eq!( + input.url, + "https://api.incident.io/v2/alert_events/http/xyz" + ); + + let headers = input.headers.clone().unwrap(); + assert_eq!( + headers.get("Authorization"), + Some(&"Bearer token".to_string()) + ); + assert_eq!(headers.get("X-Custom"), Some(&"value".to_string())); + let query_params = input.query_params.clone().unwrap(); + assert_eq!(query_params.get("source"), Some(&"clickstack".to_string())); + assert_eq!(query_params.get("env"), Some(&"prod".to_string())); + + // Maps serialize back as JSON objects, and the enum keeps its wire value. + let v = serde_json::to_value(&input).unwrap(); + assert_eq!(v["service"], "incidentio"); + assert!(v["headers"].is_object()); + assert_eq!(v["headers"]["Authorization"], "Bearer token"); + assert!(v["queryParams"].is_object()); + assert_eq!(v["queryParams"]["source"], "clickstack"); + + let back: ClickStackWebhookInput = serde_json::from_value(v).unwrap(); + assert_eq!(back, input); +} + +#[test] +fn serialize_clickstack_webhook_input_minimal_omits_optionals() { + let input = ClickStackWebhookInput { + name: "Slack Alerts".to_string(), + service: ClickStackWebhookInputService::Slack, + url: "https://hooks.slack.com/services/T/B/X".to_string(), + ..Default::default() + }; + let v = serde_json::to_value(&input).unwrap(); + assert_eq!(v["name"], "Slack Alerts"); + assert_eq!(v["service"], "slack"); + assert_eq!(v["url"], "https://hooks.slack.com/services/T/B/X"); + assert!(v.get("description").is_none()); + assert!(v.get("body").is_none()); + assert!(v.get("headers").is_none()); + assert!(v.get("queryParams").is_none()); +} + +#[test] +fn deserialize_clickstack_webhook_dispatches_slack() { + let json = r#"{ + "id": "webhook-1", + "name": "Slack 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" + }"#; + let w: ClickStackWebhook = serde_json::from_str(json).unwrap(); + match w { + ClickStackWebhook::ClickStackSlackWebhook(s) => { + assert_eq!(s.id.as_deref(), Some("webhook-1")); + assert_eq!(s.name.as_deref(), Some("Slack Alerts")); + } + other => panic!("expected Slack webhook variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_webhook_dispatches_incidentio() { + let json = r#"{ + "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" + }"#; + let w: ClickStackWebhook = serde_json::from_str(json).unwrap(); + match w { + ClickStackWebhook::ClickStackIncidentIOWebhook(i) => { + assert_eq!(i.id.as_deref(), Some("webhook-2")); + assert_eq!(i.name.as_deref(), Some("Incident Alerts")); + } + other => panic!("expected IncidentIO webhook variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_webhook_dispatches_generic_preserves_body() { + let json = r#"{ + "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" + }"#; + let w: ClickStackWebhook = serde_json::from_str(json).unwrap(); + match w { + ClickStackWebhook::ClickStackGenericWebhook(g) => { + assert_eq!(g.id.as_deref(), Some("webhook-3")); + assert_eq!(g.body.as_deref(), Some("{\"text\": \"{{ message }}\"}")); + } + other => panic!("expected Generic webhook variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_webhook_dispatches_slack_api() { + let json = r#"{ + "id": "webhook-4", + "name": "Slack API Alerts", + "service": "slack_api", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-06-15T10:30:00.000Z" + }"#; + let w: ClickStackWebhook = serde_json::from_str(json).unwrap(); + match w { + ClickStackWebhook::ClickStackSlackAPIWebhook(s) => { + assert_eq!(s.id.as_deref(), Some("webhook-4")); + assert_eq!(s.name.as_deref(), Some("Slack API Alerts")); + } + other => panic!("expected SlackAPI webhook variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_webhook_dispatches_pagerduty_api() { + let json = r#"{ + "id": "webhook-5", + "name": "PagerDuty Alerts", + "service": "pagerduty_api", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-06-15T10:30:00.000Z" + }"#; + let w: ClickStackWebhook = serde_json::from_str(json).unwrap(); + match w { + ClickStackWebhook::ClickStackPagerDutyAPIWebhook(p) => { + assert_eq!(p.id.as_deref(), Some("webhook-5")); + assert_eq!(p.name.as_deref(), Some("PagerDuty Alerts")); + } + other => panic!("expected PagerDutyAPI webhook variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_webhook_unknown_service_round_trips() { + let json = r#"{ + "id": "webhook-6", + "name": "Future Alerts", + "service": "future_service", + "extraField": "kept", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-06-15T10:30:00.000Z" + }"#; + assert_unknown_variant_round_trips(json, |w: &ClickStackWebhook| { + matches!(w, ClickStackWebhook::Unknown(_)) + }); +} + +#[test] +fn clickstack_source_expression_fields_missing_or_null_are_none() { + // `orderByExpression` (log, trace), the log-only OpenTelemetry + // `eventNameExpression` and `severityNumberExpression`, + // `resourceAttributesExpression` (session), and the trace-only display, + // sampling and span-link expressions are optional in the spec: absent or `null` lands as `None` + // on the response variants and is omitted from the request variants. + let log = serde_json::json!({ + "kind": "log", + "name": "logs", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "from": {"databaseName": "default", "tableName": "logs"}, + "timestampValueExpression": "ts", + }); + let trace = serde_json::json!({ + "kind": "trace", + "name": "traces", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "durationExpression": "Duration", + "durationPrecision": 9, + "from": {"databaseName": "default", "tableName": "traces"}, + "parentSpanIdExpression": "ParentSpanId", + "spanIdExpression": "SpanId", + "spanKindExpression": "SpanKind", + "spanNameExpression": "SpanName", + "timestampValueExpression": "Timestamp", + "traceIdExpression": "TraceId", + }); + let session = serde_json::json!({ + "kind": "session", + "name": "sessions", + "connection": "conn-1", + "from": {"databaseName": "default", "tableName": "sessions"}, + "traceSourceId": "trace-1", + }); + let with_nulls = |base: &serde_json::Value, keys: &[&str]| { + let mut v = base.clone(); + for key in keys { + v[*key] = serde_json::Value::Null; + } + v + }; + let trace_keys = [ + "displayedTimestampValueExpression", + "orderByExpression", + "sampleRateExpression", + "spanLinksValueExpression", + ]; + + let log_keys = [ + "eventNameExpression", + "orderByExpression", + "severityNumberExpression", + ]; + + for payload in [log.clone(), with_nulls(&log, &log_keys)] { + let response: ClickStackLogSourceResponse = serde_json::from_value(payload).unwrap(); + assert_eq!(response.event_name_expression, None); + assert_eq!(response.order_by_expression, None); + assert_eq!(response.severity_number_expression, None); + let request = ClickStackLogSource::try_from(response).unwrap(); + assert_eq!(serde_json::to_value(&request).unwrap(), log); + } + for payload in [trace.clone(), with_nulls(&trace, &trace_keys)] { + let response: ClickStackTraceSourceResponse = serde_json::from_value(payload).unwrap(); + assert_eq!(response.displayed_timestamp_value_expression, None); + assert_eq!(response.order_by_expression, None); + assert_eq!(response.sample_rate_expression, None); + assert_eq!(response.span_links_value_expression, None); + let request = ClickStackTraceSource::try_from(response).unwrap(); + assert_eq!(serde_json::to_value(&request).unwrap(), trace); + } + for payload in [ + session.clone(), + with_nulls(&session, &["resourceAttributesExpression"]), + ] { + let response: ClickStackSessionSourceResponse = serde_json::from_value(payload).unwrap(); + assert_eq!(response.resource_attributes_expression, None); + let request = ClickStackSessionSource::try_from(response).unwrap(); + assert_eq!(serde_json::to_value(&request).unwrap(), session); + } +} + +#[test] +fn clickstack_source_expression_fields_serialize_with_their_wire_names() { + let log = ClickStackLogSource { + event_name_expression: Some("EventName".to_string()), + order_by_expression: Some("Timestamp DESC".to_string()), + severity_number_expression: Some("SeverityNumber".to_string()), + ..Default::default() + }; + let v = serde_json::to_value(&log).unwrap(); + assert_eq!(v["eventNameExpression"], "EventName"); + assert_eq!(v["orderByExpression"], "Timestamp DESC"); + assert_eq!(v["severityNumberExpression"], "SeverityNumber"); + + let session = ClickStackSessionSource { + resource_attributes_expression: Some("ResourceAttributes".to_string()), + ..Default::default() + }; + let v = serde_json::to_value(&session).unwrap(); + assert_eq!(v["resourceAttributesExpression"], "ResourceAttributes"); + + let trace = ClickStackTraceSource { + displayed_timestamp_value_expression: Some("Timestamp".to_string()), + order_by_expression: Some("Timestamp DESC".to_string()), + sample_rate_expression: Some("SampleRate".to_string()), + span_links_value_expression: Some("Links".to_string()), + ..Default::default() + }; + let v = serde_json::to_value(&trace).unwrap(); + assert_eq!(v["displayedTimestampValueExpression"], "Timestamp"); + assert_eq!(v["orderByExpression"], "Timestamp DESC"); + assert_eq!(v["sampleRateExpression"], "SampleRate"); + assert_eq!(v["spanLinksValueExpression"], "Links"); +} + +#[test] +fn clickstack_log_source_otel_expressions_write_back() { + let wire = serde_json::json!({ + "kind": "log", + "name": "logs", + "connection": "conn-1", + "defaultTableSelectExpression": "*", + "from": {"databaseName": "default", "tableName": "logs"}, + "timestampValueExpression": "Timestamp", + "eventNameExpression": "EventName", + "severityNumberExpression": "SeverityNumber", + "severityTextExpression": "SeverityText", + }); + let response: ClickStackLogSourceResponse = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(response.event_name_expression.as_deref(), Some("EventName")); + assert_eq!( + response.severity_number_expression.as_deref(), + Some("SeverityNumber") + ); + let request = ClickStackLogSource::try_from(response).unwrap(); + assert_eq!(request.event_name_expression.as_deref(), Some("EventName")); + assert_eq!( + request.severity_number_expression.as_deref(), + Some("SeverityNumber") + ); + assert_eq!(serde_json::to_value(&request).unwrap(), wire); +} + +#[test] +fn clickstack_validate_webhook_response_null_fields_are_none() { + let null: ClickStackValidateWebhookResponse = + serde_json::from_str(r#"{"valid": null, "errors": null, "normalized": null}"#).unwrap(); + assert_eq!(null, ClickStackValidateWebhookResponse::default()); + // Absent fields are omitted on serialize, not emitted as `null`. + assert_eq!(serde_json::to_value(&null).unwrap(), serde_json::json!({})); + + let item: ClickStackValidateWebhookResponseErrorsItem = + serde_json::from_str(r#"{"path": null, "message": null}"#).unwrap(); + assert_eq!(item, ClickStackValidateWebhookResponseErrorsItem::default()); + + let resp: ClickStackValidateWebhookResponse = serde_json::from_str( + r#"{"valid": false, "errors": [{"path": "", "message": "Top-level error"}], + "normalized": {"name": "hook"}}"#, + ) + .unwrap(); + assert_eq!(resp.valid, Some(false)); + let errors = resp.errors.as_ref().expect("errors should populate"); + assert_eq!(errors[0].path.as_deref(), Some("")); + assert_eq!(errors[0].message.as_deref(), Some("Top-level error")); + // `normalized` is a free-form Value; its payload is preserved verbatim. + assert_eq!(resp.normalized.as_ref().unwrap()["name"], "hook"); +} + +#[test] +fn clickstack_validate_dashboard_response_round_trip() { + let json = r#"{ + "valid": false, + "errors": [ + {"path": "tiles.0.config", "message": "Required"}, + {"path": "", "message": "Top-level error"} + ], + "normalized": {"name": "My Dashboard", "tiles": [{"id": "t1"}]} + }"#; + let resp: ClickStackValidateDashboardResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.valid, Some(false)); + let errors = resp.errors.as_ref().expect("errors should populate"); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0].path.as_deref(), Some("tiles.0.config")); + assert_eq!(errors[0].message.as_deref(), Some("Required")); + assert_eq!(errors[1].path.as_deref(), Some("")); + + // `normalized` is a free-form Value; its arbitrary payload is preserved. + let normalized = resp.normalized.as_ref().unwrap(); + assert_eq!(normalized["name"], "My Dashboard"); + assert_eq!(normalized["tiles"][0]["id"], "t1"); + + let v = serde_json::to_value(&resp).unwrap(); + assert_eq!(v["valid"], false); + assert_eq!(v["errors"][0]["path"], "tiles.0.config"); + assert_eq!(v["normalized"]["name"], "My Dashboard"); + + let back: ClickStackValidateDashboardResponse = serde_json::from_value(v).unwrap(); + assert_eq!(back, resp); +} + +#[test] +fn clickstack_validate_dashboard_response_valid_null_normalized() { + let json = r#"{"valid": true, "errors": [], "normalized": null}"#; + let resp: ClickStackValidateDashboardResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.valid, Some(true)); + assert_eq!(resp.errors.as_deref(), Some(&[][..])); + assert_eq!(resp.normalized, None); + + // An explicit `null` lands as `None` and, like every absent response + // field, is omitted on the way out rather than re-emitted as `null`. + let v = serde_json::to_value(&resp).unwrap(); + assert!( + v.get("normalized").is_none(), + "absent response fields must be omitted from --json output" + ); +} + +#[test] +fn deserialize_clickstack_dashboard_chart_series_dispatches_time() { + // The manual `type`-keyed dispatch routes "time" to the time-series variant. + let json = r#"{ + "aggFn": "count", + "groupBy": ["service"], + "sourceId": "src-1", + "type": "time", + "where": "x = 1", + "whereLanguage": "sql" + }"#; + let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); + match &series { + ClickStackDashboardChartSeries::ClickStackTimeChartSeries(s) => { + assert_eq!(s.r#type, ClickStackTimeChartSeriesType::Time); + assert_eq!(s.agg_fn, ClickStackTimeChartSeriesAggfn::Count); + } + other => panic!("expected time variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_dashboard_chart_series_dispatches_table() { + // Regression: Time and Table share every required field (aggFn, groupBy, + // sourceId, type, where, whereLanguage), so before manual discriminator + // dispatch a `type: "table"` payload greedily resolved to the Time variant + // and silently dropped table-only fields like `sortOrder`. The union now + // routes on the `type` discriminator and preserves `sortOrder`. + let json = r#"{ + "aggFn": "count", + "groupBy": ["service"], + "sortOrder": "desc", + "sourceId": "src-2", + "type": "table", + "where": "y = 2", + "whereLanguage": "sql" + }"#; + let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); + match &series { + ClickStackDashboardChartSeries::ClickStackTableChartSeries(s) => { + assert_eq!(s.r#type, ClickStackTableChartSeriesType::Table); + assert_eq!( + s.sort_order, + Some(ClickStackTableChartSeriesSortorder::Desc) + ); + } + other => panic!("expected table variant, got {other}"), + } + // The table-only `sortOrder` survives the round-trip through the union. + let v = serde_json::to_value(&series).unwrap(); + assert_eq!(v["type"], "table"); + assert_eq!(v["sortOrder"], "desc"); +} + +#[test] +fn deserialize_clickstack_dashboard_chart_series_dispatches_number() { + // The manual `type`-keyed dispatch routes "number" to the number-series variant. + let json = r#"{ + "aggFn": "count", + "sourceId": "src-3", + "type": "number", + "where": "z = 3", + "whereLanguage": "sql" + }"#; + let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); + match &series { + ClickStackDashboardChartSeries::ClickStackNumberChartSeries(s) => { + assert_eq!(s.r#type, ClickStackNumberChartSeriesType::Number); + } + other => panic!("expected number variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_dashboard_chart_series_dispatches_search() { + // The manual `type`-keyed dispatch routes "search" to the search-series variant. + let json = r#"{ + "fields": ["message"], + "sourceId": "src-4", + "type": "search", + "where": "w = 4", + "whereLanguage": "sql" + }"#; + let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); + match &series { + ClickStackDashboardChartSeries::ClickStackSearchChartSeries(s) => { + assert_eq!(s.r#type, ClickStackSearchChartSeriesType::Search); + assert_eq!(s.fields, vec!["message".to_string()]); + } + other => panic!("expected search variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_dashboard_chart_series_dispatches_markdown() { + // The manual `type`-keyed dispatch routes "markdown" to the markdown-series variant. + let json = r##"{"content": "# Title", "type": "markdown"}"##; + let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); + match &series { + ClickStackDashboardChartSeries::ClickStackMarkdownChartSeries(s) => { + assert_eq!(s.r#type, ClickStackMarkdownChartSeriesType::Markdown); + assert_eq!(s.content, "# Title"); + } + other => panic!("expected markdown variant, got {other}"), + } +} + +#[test] +fn deserialize_clickstack_dashboard_chart_series_unknown_type_round_trip() { + // An unrecognized `type` discriminator falls back to the Unknown variant, + // which stores the raw JSON so it round-trips faithfully. + let json = r#"{"type":"heatmap","payload":{"nested":[1,2,3]}}"#; + let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); + match &series { + ClickStackDashboardChartSeries::Unknown(v) => { + assert_eq!(v["type"], "heatmap"); + assert_eq!(v["payload"]["nested"][2], 3); + } + other => panic!("expected unknown variant, got {other}"), + } + let expected: serde_json::Value = serde_json::from_str(json).unwrap(); + assert_eq!(serde_json::to_value(&series).unwrap(), expected); +} + +#[test] +fn saved_filter_and_formula_writeback_reports_required_wire_fields() { + let sql: ClickStackSavedFilterValueResponse = + serde_json::from_value(serde_json::json!({"condition": "x = 1"})).unwrap(); + let request = ClickStackSavedFilterValue::try_from(sql).unwrap(); + assert_eq!( + serde_json::to_value(request).unwrap(), + serde_json::json!({"condition": "x = 1"}) + ); + let variable = serde_json::json!({"type": "variable", "name": "service", "values": ["api"]}); + let response: ClickStackSavedFilterValueResponse = + serde_json::from_value(variable.clone()).unwrap(); + assert_eq!( + serde_json::to_value(ClickStackSavedFilterValue::try_from(response).unwrap()).unwrap(), + variable + ); + assert_eq!( + ClickStackSqlSavedFilterValue::try_from(ClickStackSqlSavedFilterValueResponse::default()) + .unwrap_err() + .fields(), + &["condition"] + ); + assert_eq!( + ClickStackVariableSavedFilterValue::try_from( + ClickStackVariableSavedFilterValueResponse::default() + ) + .unwrap_err() + .fields(), + &["type", "name", "values"] + ); + assert_eq!( + ClickStackFormula::try_from(ClickStackFormulaResponse::default()) + .unwrap_err() + .fields(), + &["expression"] + ); + let formula = ClickStackFormula::try_from(ClickStackFormulaResponse { + expression: Some("A / B".into()), + alias: Some("Rate".into()), + number_format: None, + }) + .unwrap(); + assert_eq!(formula.expression, "A / B"); + assert_eq!(formula.alias.as_deref(), Some("Rate")); + let invalid = ClickStackFormulaResponse { + expression: Some("A".into()), + number_format: Some(ClickStackNumberFormatResponse::default()), + ..Default::default() + }; + assert!( + ClickStackFormula::try_from(invalid) + .unwrap_err() + .fields() + .contains(&"currencySymbol") + ); +} + +#[test] +fn new_clickstack_channels_and_chart_fields_round_trip() { + let request = ClickStackCreateAlertRequest { + interval: ClickStackCreateAlertRequestInterval::Value30s, + channel: Some(ClickStackAlertChannel::default()), + channels: Some(vec![ClickStackAlertChannel::default()]), + ..Default::default() + }; + let wire = serde_json::to_value(&request).unwrap(); + assert_eq!(wire["channels"].as_array().unwrap().len(), 1); + assert_eq!(wire["interval"], "30s"); + assert!(wire.get("channel").is_some()); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + request + ); + let chart: ClickStackLineBuilderChartConfigResponse = + serde_json::from_value(serde_json::json!({ + "formulas": [{"expression": "A / B", "alias": "Rate"}], "seriesLimit": 15, + "showOperandSeries": false + })) + .unwrap(); + assert_eq!(chart.series_limit, Some(15)); + assert_eq!(chart.show_operand_series, Some(false)); + assert_eq!( + chart.formulas.unwrap()[0].expression.as_deref(), + Some("A / B") + ); +} + +#[test] +fn clickstack_nested_writeback_preserves_number_format_and_source_expressions() { + let formula = serde_json::json!({ + "expression": "A / B", "alias": "Success rate", "numberFormat": { + "average": false, "currencySymbol": "$", "decimalBytes": true, + "factor": 100.0, "mantissa": 2, "numericUnit": "bytes_si", + "output": "percent", "thousandSeparated": true, "unit": "%" + } + }); + let response: ClickStackFormulaResponse = serde_json::from_value(formula.clone()).unwrap(); + let request = ClickStackFormula::try_from(response).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), formula); + + let log = serde_json::json!({ + "kind": "log", "connection": "connection-1", "name": "Application logs", + "defaultTableSelectExpression": "*", "timestampValueExpression": "Timestamp", + "from": {"databaseName": "default", "tableName": "otel_logs"}, + "serviceVersionExpression": "ResourceAttributes['service.version']", + "filterSettings": { + "databaseName": "default", "tableName": "service_names", + "columns": [{"name": "ServiceName", "label": "Service", "allowAll": true, + "valueExpression": "lower(service_name)"}] + } + }); + let response: ClickStackSourceResponse = serde_json::from_value(log.clone()).unwrap(); + let request = ClickStackSource::try_from(response).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), log); + + let mut trace = log; + trace["kind"] = "trace".into(); + trace["name"] = "Application traces".into(); + trace["from"]["tableName"] = "otel_traces".into(); + trace["durationExpression"] = "Duration".into(); + trace["durationPrecision"] = 9.into(); + trace["parentSpanIdExpression"] = "ParentSpanId".into(); + trace["spanIdExpression"] = "SpanId".into(); + trace["spanKindExpression"] = "SpanKind".into(); + trace["spanNameExpression"] = "SpanName".into(); + trace["traceIdExpression"] = "TraceId".into(); + let response: ClickStackSourceResponse = serde_json::from_value(trace.clone()).unwrap(); + let request = ClickStackSource::try_from(response).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), trace); +} + +/// One spec-shaped payload per `ClickStackFilter` branch, each built from every +/// property of its `*FilterInput` schema plus the `id` the full schema adds. +fn clickstack_filter_kind_payloads() -> [(&'static str, serde_json::Value); 4] { + [ + ( + "QUERY_EXPRESSION", + serde_json::json!({ + "type": "QUERY_EXPRESSION", "id": "f-1", "name": "Environment", + "expression": "environment", "sourceId": "src-1", + "sourceMetricType": "exponential histogram", "where": "ServiceName:api", + "whereLanguage": "lucene", "appliesToSourceIds": ["src-1"], + "isBroadcastEnabled": true, "isVariableEnabled": true, + "variableName": "environment", "minSelections": 1, + "isGlobalRequirement": true, "maxSelections": 1 + }), + ), + ( + "STATIC_LIST", + serde_json::json!({ + "type": "STATIC_LIST", "id": "f-2", "name": "Environment", + "options": ["prod", "staging"], "isBroadcastEnabled": false, + "isVariableEnabled": true, "variableName": "environment", + "minSelections": 0, "isGlobalRequirement": false, "maxSelections": 1 + }), + ), + ( + "PROMETHEUS_LABEL", + serde_json::json!({ + "type": "PROMETHEUS_LABEL", "id": "f-3", "name": "Pod", + "sourceId": "src-2", "label": "pod", "match": "up{job=\"api\"}", + "isBroadcastEnabled": false, "isVariableEnabled": true, + "variableName": "pod", "minSelections": 1, + "isGlobalRequirement": false, "maxSelections": 1 + }), + ), + ( + "SOURCE_FILTER", + serde_json::json!({ + "type": "SOURCE_FILTER", "id": "f-4", "name": "Cluster", + "sourceId": "src-3", "expression": "cluster", + "appliesToSourceIds": ["src-3"], "isBroadcastEnabled": true, + "isVariableEnabled": true, "variableName": "cluster", + "minSelections": 0, "isGlobalRequirement": true, "maxSelections": 1 + }), + ), + ] +} + +#[test] +fn clickstack_filter_unions_route_each_kind_to_its_variant() { + // Dispatch reads `type` alone, so each spec-shaped payload must land in its + // own arm of the update-request, create-request and response unions — and + // never in the first arm that happens to deserialize — then serialize back + // unchanged. + for (kind, payload) in clickstack_filter_kind_payloads() { + let filter: ClickStackFilter = serde_json::from_value(payload.clone()).unwrap(); + let matched = match (kind, &filter) { + ("QUERY_EXPRESSION", ClickStackFilter::ClickStackQueryExpressionFilter(f)) => { + assert_eq!(f.id, "f-1"); + assert_eq!(f.expression, "environment"); + assert_eq!( + f.source_metric_type, + Some( + ClickStackQueryExpressionFilterInputSourcemetrictype::Exponential_histogram + ) + ); + assert_eq!( + f.where_language, + Some(ClickStackQueryExpressionFilterInputWherelanguage::Lucene) + ); + assert_eq!(f.min_selections, Some(ClickStackFilterMinSelections::One)); + assert_eq!(f.max_selections, Some(ClickStackFilterMaxSelections::One)); + true + } + ("STATIC_LIST", ClickStackFilter::ClickStackStaticListFilter(f)) => { + assert_eq!(f.id, "f-2"); + assert_eq!(f.options, ["prod", "staging"]); + assert_eq!(f.min_selections, Some(ClickStackFilterMinSelections::Zero)); + true + } + ("PROMETHEUS_LABEL", ClickStackFilter::ClickStackPrometheusLabelFilter(f)) => { + assert_eq!(f.id, "f-3"); + assert_eq!(f.label, "pod"); + assert_eq!(f.r#match.as_deref(), Some("up{job=\"api\"}")); + true + } + ("SOURCE_FILTER", ClickStackFilter::ClickStackSourceFilter(f)) => { + assert_eq!(f.id, "f-4"); + assert_eq!(f.variable_name, "cluster"); + assert_eq!(f.is_global_requirement, Some(true)); + true + } + _ => false, + }; + assert!(matched, "{kind} routed to {filter:?}"); + assert_eq!(serde_json::to_value(&filter).unwrap(), payload); + + let mut input_payload = payload.clone(); + input_payload.as_object_mut().unwrap().remove("id"); + let input: ClickStackFilterInput = serde_json::from_value(input_payload.clone()).unwrap(); + let matched = matches!( + (kind, &input), + ( + "QUERY_EXPRESSION", + ClickStackFilterInput::ClickStackQueryExpressionFilterInput(_) + ) | ( + "STATIC_LIST", + ClickStackFilterInput::ClickStackStaticListFilterInput(_) + ) | ( + "PROMETHEUS_LABEL", + ClickStackFilterInput::ClickStackPrometheusLabelFilterInput(_) + ) | ( + "SOURCE_FILTER", + ClickStackFilterInput::ClickStackSourceFilterInput(_) + ) + ); + assert!(matched, "{kind} input routed to {input:?}"); + assert_eq!(serde_json::to_value(&input).unwrap(), input_payload); + + let response: ClickStackFilterResponse = serde_json::from_value(payload.clone()).unwrap(); + let matched = match (kind, &response) { + ("QUERY_EXPRESSION", ClickStackFilterResponse::ClickStackQueryExpressionFilter(f)) => { + f.expression.as_deref() == Some("environment") + } + ("STATIC_LIST", ClickStackFilterResponse::ClickStackStaticListFilter(f)) => { + f.options.as_deref() == Some(&["prod".to_string(), "staging".to_string()][..]) + } + ("PROMETHEUS_LABEL", ClickStackFilterResponse::ClickStackPrometheusLabelFilter(f)) => { + f.label.as_deref() == Some("pod") + } + ("SOURCE_FILTER", ClickStackFilterResponse::ClickStackSourceFilter(f)) => { + f.variable_name.as_deref() == Some("cluster") + } + _ => false, + }; + assert!(matched, "{kind} response routed to {response:?}"); + assert_eq!(serde_json::to_value(&response).unwrap(), payload); + } +} + +#[test] +fn clickstack_filter_dispatch_ignores_the_payload_shape() { + // A static-list body relabelled as a source filter is still a source filter + // in the tolerant response union: dispatch never falls through to whichever + // all-`Option` arm accepts the shape. + let mut payload = clickstack_filter_kind_payloads()[1].1.clone(); + payload["type"] = "SOURCE_FILTER".into(); + let response: ClickStackFilterResponse = serde_json::from_value(payload.clone()).unwrap(); + assert!( + matches!( + response, + ClickStackFilterResponse::ClickStackSourceFilter(_) + ), + "{response:?}" + ); + // The strict request union cannot fit it (a source filter needs + // `expression` and `sourceId`), so it keeps the raw JSON rather than + // retrying the static-list arm the shape would fit. + let filter: ClickStackFilter = serde_json::from_value(payload.clone()).unwrap(); + assert_eq!(filter, ClickStackFilter::Unknown(payload)); +} + +#[test] +fn clickstack_filter_unknown_or_missing_type_round_trips() { + for json in [ + r#"{"type":"FUTURE_KIND","id":"f-9","name":"x"}"#, + r#"{"id":"f-9","name":"x","expression":"e","sourceId":"s"}"#, + ] { + assert_unknown_variant_round_trips(json, |f: &ClickStackFilter| { + matches!(f, ClickStackFilter::Unknown(_)) + }); + assert_unknown_variant_round_trips(json, |f: &ClickStackFilterInput| { + matches!(f, ClickStackFilterInput::Unknown(_)) + }); + assert_unknown_variant_round_trips(json, |f: &ClickStackFilterResponse| { + matches!(f, ClickStackFilterResponse::Unknown(_)) + }); + } +} + +#[test] +fn clickstack_filter_response_treats_dropped_and_null_fields_as_absent() { + for (kind, _) in clickstack_filter_kind_payloads() { + for json in [ + serde_json::json!({"type": kind}), + serde_json::json!({ + "type": kind, "id": null, "name": null, "expression": null, + "sourceId": null, "options": null, "label": null, "match": null, + "sourceMetricType": null, "where": null, "whereLanguage": null, + "appliesToSourceIds": null, "isBroadcastEnabled": null, + "isVariableEnabled": null, "variableName": null, "minSelections": null, + "isGlobalRequirement": null, "maxSelections": null + }), + ] { + let response: ClickStackFilterResponse = serde_json::from_value(json).unwrap(); + let sparse = match &response { + ClickStackFilterResponse::ClickStackQueryExpressionFilter(f) => { + f.id.is_none() + && f.expression.is_none() + && f.source_metric_type.is_none() + && f.min_selections.is_none() + && f.max_selections.is_none() + && f.is_global_requirement.is_none() + } + ClickStackFilterResponse::ClickStackStaticListFilter(f) => { + f.id.is_none() && f.options.is_none() && f.variable_name.is_none() + } + ClickStackFilterResponse::ClickStackPrometheusLabelFilter(f) => { + f.label.is_none() && f.r#match.is_none() && f.source_id.is_none() + } + ClickStackFilterResponse::ClickStackSourceFilter(f) => { + f.expression.is_none() && f.variable_name.is_none() + } + ClickStackFilterResponse::Unknown(_) => false, + }; + assert!(sparse, "{kind}: {response:?}"); + // Absent fields are omitted, never re-emitted as `null`. + assert_eq!( + serde_json::to_value(&response).unwrap(), + serde_json::json!({"type": kind}) + ); + } + } +} + +#[test] +fn clickstack_filter_try_from_response_converts_every_kind() { + for (kind, payload) in clickstack_filter_kind_payloads() { + let response: ClickStackFilterResponse = serde_json::from_value(payload.clone()).unwrap(); + let request = ClickStackFilter::try_from(response).unwrap(); + assert!( + !matches!(request, ClickStackFilter::Unknown(_)), + "{kind}: {request:?}" + ); + assert_eq!(serde_json::to_value(&request).unwrap(), payload); + } +} + +#[test] +fn clickstack_filter_try_from_response_names_every_missing_required_field() { + for (json, expected) in [ + ( + r#"{"type":"QUERY_EXPRESSION"}"#, + &["expression", "id", "name", "sourceId"][..], + ), + ( + r#"{"type":"STATIC_LIST","id":"f"}"#, + &["name", "options"][..], + ), + ( + r#"{"type":"PROMETHEUS_LABEL","name":"n"}"#, + &["id", "label", "sourceId"][..], + ), + ( + r#"{"type":"SOURCE_FILTER","id":"f","name":"n","sourceId":"s"}"#, + &["expression", "variableName"][..], + ), + ] { + let response: ClickStackFilterResponse = serde_json::from_str(json).unwrap(); + let error = ClickStackFilter::try_from(response).unwrap_err(); + assert_eq!(error.fields(), expected, "{json}"); + } + + // An unmodelled kind passes through losslessly. + let json = r#"{"type":"FUTURE_KIND","id":"f"}"#; + let response: ClickStackFilterResponse = serde_json::from_str(json).unwrap(); + let request = ClickStackFilter::try_from(response).unwrap(); + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::from_str::(json).unwrap() + ); +} + +#[test] +fn clickstack_filter_request_variants_stay_strict() { + // Each create-request filter variant requires its schema's required fields. + macro_rules! assert_rejects_empty { + ($($model:ty),+ $(,)?) => { + $( + assert!( + serde_json::from_str::<$model>("{}").is_err(), + "{} accepted a payload missing its required fields", + stringify!($model), + ); + )+ + }; + } + assert_rejects_empty!( + ClickStackQueryExpressionFilterInput, + ClickStackStaticListFilterInput, + ClickStackPrometheusLabelFilterInput, + ClickStackSourceFilterInput, + ); + // The update-request variant adds a required `id` on top of the input. + let mut payload = clickstack_filter_kind_payloads()[0].1.clone(); + payload.as_object_mut().unwrap().remove("id"); + assert!(serde_json::from_value::(payload.clone()).is_err()); + serde_json::from_value::(payload).unwrap(); +} + +#[test] +fn clickstack_filter_selection_counts_are_integer_enums() { + let filter: ClickStackStaticListFilterInput = serde_json::from_value(serde_json::json!({ + "type": "STATIC_LIST", "name": "n", "options": ["a"], + "minSelections": 1, "maxSelections": 1 + })) + .unwrap(); + assert_eq!( + filter.min_selections, + Some(ClickStackFilterMinSelections::One) + ); + assert_eq!( + filter.max_selections, + Some(ClickStackFilterMaxSelections::One) + ); + let v = serde_json::to_value(&filter).unwrap(); + assert_eq!(v["minSelections"], 1); + assert_eq!(v["maxSelections"], 1); + + // A value the spec does not list is kept, not rejected. + let min: ClickStackFilterMinSelections = serde_json::from_str("2").unwrap(); + assert_eq!(min, ClickStackFilterMinSelections::Unknown(2)); + assert_eq!(serde_json::to_string(&min).unwrap(), "2"); + assert_eq!(min.to_string(), "2"); + let zero: ClickStackFilterMinSelections = serde_json::from_str("0").unwrap(); + assert_eq!(zero, ClickStackFilterMinSelections::Zero); + let max: ClickStackFilterMaxSelections = serde_json::from_str("3").unwrap(); + assert_eq!(max, ClickStackFilterMaxSelections::Unknown(3)); +} + +#[test] +fn clickstack_dashboard_response_carries_every_filter_kind() { + let filters: Vec = clickstack_filter_kind_payloads() + .into_iter() + .map(|(_, payload)| payload) + .collect(); + let dashboard: ClickStackDashboardResponse = serde_json::from_value(serde_json::json!({ + "id": "d-1", "name": "Overview", "tiles": [], "filters": filters + })) + .unwrap(); + assert!(matches!( + dashboard.filters.as_deref(), + Some([ + ClickStackFilterResponse::ClickStackQueryExpressionFilter(_), + ClickStackFilterResponse::ClickStackStaticListFilter(_), + ClickStackFilterResponse::ClickStackPrometheusLabelFilter(_), + ClickStackFilterResponse::ClickStackSourceFilter(_), + ]) + )); +} + +#[test] +fn clickstack_heatmap_chart_config_routes_each_mode_to_its_variant() { + let distribution = serde_json::json!({ + "displayType": "heatmap", "heatmapMode": "distribution", "sourceId": "src-1", + "select": [{"valueExpression": "Duration", "countExpression": "count()", "heatmapScaleType": "log"}], + "where": "ServiceName = 'api'", "whereLanguage": "sql" + }); + let series = serde_json::json!({ + "displayType": "heatmap", "heatmapMode": "series", "sourceId": "src-1", + "select": [{"aggFn": "count"}], "groupBy": "ServiceName" + }); + let mut implicit_distribution = distribution.clone(); + implicit_distribution + .as_object_mut() + .unwrap() + .remove("heatmapMode"); + + for payload in [&distribution, &implicit_distribution] { + let config: ClickStackHeatmapChartConfig = serde_json::from_value(payload.clone()).unwrap(); + let ClickStackHeatmapChartConfig::ClickStackHeatmapDistributionChartConfig(d) = &config + else { + panic!("expected the distribution variant, got {config:?}"); + }; + assert_eq!(d.select[0].value_expression, "Duration"); + assert_eq!( + d.where_language, + Some(ClickStackHeatmapDistributionChartConfigWherelanguage::Sql) + ); + assert_eq!(serde_json::to_value(&config).unwrap(), *payload); + + let response: ClickStackHeatmapChartConfigResponse = + serde_json::from_value(payload.clone()).unwrap(); + assert!( + matches!( + response, + ClickStackHeatmapChartConfigResponse::ClickStackHeatmapDistributionChartConfig(_) + ), + "{response:?}" + ); + } + + let config: ClickStackHeatmapChartConfig = serde_json::from_value(series.clone()).unwrap(); + let ClickStackHeatmapChartConfig::ClickStackHeatmapSeriesChartConfig(s) = &config else { + panic!("expected the series variant, got {config:?}"); + }; + assert_eq!( + s.heatmap_mode, + ClickStackHeatmapSeriesChartConfigHeatmapmode::Series + ); + assert_eq!(s.group_by.as_deref(), Some("ServiceName")); + assert_eq!(s.select[0].agg_fn, ClickStackSelectItemAggfn::Count); + assert_eq!(serde_json::to_value(&config).unwrap(), series); + + let response: ClickStackHeatmapChartConfigResponse = + serde_json::from_value(series.clone()).unwrap(); + let ClickStackHeatmapChartConfigResponse::ClickStackHeatmapSeriesChartConfig(s) = &response + else { + panic!("expected the series variant, got {response:?}"); + }; + assert_eq!(s.group_by.as_deref(), Some("ServiceName")); + + // Reached through the tile config's `displayType: "heatmap"` arm. + let tile: ClickStackTileConfigResponse = serde_json::from_value(series.clone()).unwrap(); + assert!(matches!( + tile, + ClickStackTileConfigResponse::ClickStackHeatmapChartConfig( + ClickStackHeatmapChartConfigResponse::ClickStackHeatmapSeriesChartConfig(_) + ) + )); +} + +#[test] +fn clickstack_heatmap_chart_config_guards_series_payloads_without_a_mode() { + // A series payload whose `heatmapMode` is dropped still carries the + // series-only `groupBy`, so it must not be read as a distribution heatmap. + let json = r#"{"displayType":"heatmap","sourceId":"s","select":[{"aggFn":"count"}],"groupBy":"ServiceName"}"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackHeatmapChartConfig| { + matches!(c, ClickStackHeatmapChartConfig::Unknown(_)) + }); + assert_unknown_variant_round_trips(json, |c: &ClickStackHeatmapChartConfigResponse| { + matches!(c, ClickStackHeatmapChartConfigResponse::Unknown(_)) + }); + let json = r#"{"displayType":"heatmap","heatmapMode":"timeline","sourceId":"s"}"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackHeatmapChartConfigResponse| { + matches!(c, ClickStackHeatmapChartConfigResponse::Unknown(_)) + }); +} + +#[test] +fn clickstack_heatmap_chart_config_response_treats_dropped_and_null_fields_as_absent() { + let response: ClickStackHeatmapChartConfigResponse = + serde_json::from_str(r#"{"heatmapMode":"series","groupBy":null,"select":null}"#).unwrap(); + assert_eq!( + response, + ClickStackHeatmapChartConfigResponse::ClickStackHeatmapSeriesChartConfig( + ClickStackHeatmapSeriesChartConfigResponse { + heatmap_mode: Some(ClickStackHeatmapSeriesChartConfigHeatmapmode::Series), + ..Default::default() + } + ) + ); + let response: ClickStackHeatmapChartConfigResponse = + serde_json::from_str(r#"{"displayType":"heatmap","where":null}"#).unwrap(); + assert_eq!( + response, + ClickStackHeatmapChartConfigResponse::ClickStackHeatmapDistributionChartConfig( + ClickStackHeatmapDistributionChartConfigResponse { + display_type: Some(ClickStackHeatmapDistributionChartConfigDisplaytype::Heatmap), + ..Default::default() + } + ) + ); +} + +#[test] +fn clickstack_tile_config_stacked_line_routes_builder_and_raw_sql() { + let builder = serde_json::json!({ + "displayType": "stacked_line", "sourceId": "src-1", "select": [{"aggFn": "count"}], + "groupBy": "service", "asRatio": false, "alignDateRangeToGranularity": true, + "fillNulls": true, "lineInterpolation": "step", "seriesLimit": 5, + "formulas": [{"expression": "A * 100"}], "showOperandSeries": false + }); + let raw = serde_json::json!({ + "displayType": "stacked_line", "configType": "sql", "connectionId": "conn-1", + "sqlTemplate": "SELECT 1", "sourceId": "src-1", "fillNulls": false, + "alignDateRangeToGranularity": false, "lineInterpolation": "smooth" + }); + + let tile: ClickStackTileConfig = serde_json::from_value(builder.clone()).unwrap(); + let ClickStackTileConfig::ClickStackStackedLineChartConfig( + ClickStackStackedLineChartConfig::ClickStackStackedLineBuilderChartConfig(b), + ) = &tile + else { + panic!("expected the stacked-line builder variant, got {tile:?}"); + }; + assert_eq!( + b.display_type, + ClickStackStackedLineBuilderChartConfigDisplaytype::Stacked_line + ); + assert_eq!( + b.line_interpolation, + Some(ClickStackStackedLineBuilderChartConfigLineinterpolation::Step) + ); + assert_eq!(b.series_limit, Some(5)); + assert_eq!(serde_json::to_value(&tile).unwrap(), builder); + + let tile: ClickStackTileConfig = serde_json::from_value(raw.clone()).unwrap(); + let ClickStackTileConfig::ClickStackStackedLineChartConfig( + ClickStackStackedLineChartConfig::ClickStackStackedLineRawSqlChartConfig(r), + ) = &tile + else { + panic!("expected the stacked-line raw SQL variant, got {tile:?}"); + }; + assert_eq!(r.connection_id, "conn-1"); + assert_eq!( + r.line_interpolation, + Some(ClickStackStackedLineRawSqlChartConfigLineinterpolation::Smooth) + ); + assert_eq!(serde_json::to_value(&tile).unwrap(), raw); + + let tile: ClickStackTileConfigResponse = serde_json::from_value(builder).unwrap(); + assert!(matches!( + tile, + ClickStackTileConfigResponse::ClickStackStackedLineChartConfig( + ClickStackStackedLineChartConfigResponse::ClickStackStackedLineBuilderChartConfig(_) + ) + )); + let tile: ClickStackTileConfigResponse = serde_json::from_value(raw.clone()).unwrap(); + assert!(matches!( + tile, + ClickStackTileConfigResponse::ClickStackStackedLineChartConfig( + ClickStackStackedLineChartConfigResponse::ClickStackStackedLineRawSqlChartConfig(_) + ) + )); + + // A raw-SQL body that lost its `configType` is kept out of the builder arm. + let mut unmarked = raw; + unmarked.as_object_mut().unwrap().remove("configType"); + assert_unknown_variant_round_trips( + &unmarked.to_string(), + |c: &ClickStackStackedLineChartConfigResponse| { + matches!(c, ClickStackStackedLineChartConfigResponse::Unknown(_)) + }, + ); +} + +#[test] +fn clickstack_stacked_line_response_treats_dropped_and_null_fields_as_absent() { + let response: ClickStackStackedLineChartConfigResponse = serde_json::from_str( + r#"{"displayType":"stacked_line","lineInterpolation":null,"select":null,"seriesLimit":null}"#, + ) + .unwrap(); + assert_eq!( + response, + ClickStackStackedLineChartConfigResponse::ClickStackStackedLineBuilderChartConfig( + ClickStackStackedLineBuilderChartConfigResponse { + display_type: Some( + ClickStackStackedLineBuilderChartConfigDisplaytype::Stacked_line + ), + ..Default::default() + } + ) + ); + let response: ClickStackStackedLineChartConfigResponse = + serde_json::from_str(r#"{"configType":"sql","connectionId":null}"#).unwrap(); + assert_eq!( + response, + ClickStackStackedLineChartConfigResponse::ClickStackStackedLineRawSqlChartConfig( + ClickStackStackedLineRawSqlChartConfigResponse { + config_type: Some(ClickStackStackedLineRawSqlChartConfigConfigtype::Sql), + ..Default::default() + } + ) + ); +} + +#[test] +fn clickstack_line_charts_carry_line_interpolation() { + let builder: ClickStackLineBuilderChartConfig = serde_json::from_value(serde_json::json!({ + "displayType": "line", "sourceId": "s", "select": [], "lineInterpolation": "linear" + })) + .unwrap(); + assert_eq!( + builder.line_interpolation, + Some(ClickStackLineBuilderChartConfigLineinterpolation::Linear) + ); + assert_eq!( + serde_json::to_value(&builder).unwrap()["lineInterpolation"], + "linear" + ); + let raw: ClickStackLineRawSqlChartConfig = serde_json::from_value(serde_json::json!({ + "displayType": "line", "configType": "sql", "connectionId": "c", + "sqlTemplate": "SELECT 1", "lineInterpolation": "step" + })) + .unwrap(); + assert_eq!( + raw.line_interpolation, + Some(ClickStackLineRawSqlChartConfigLineinterpolation::Step) + ); + let unknown: ClickStackLineBuilderChartConfigLineinterpolation = + serde_json::from_str(r#""cubic""#).unwrap(); + assert_eq!( + unknown, + ClickStackLineBuilderChartConfigLineinterpolation::Unknown("cubic".to_string()) + ); + + // Absent or null in a response is `None`, and absent is omitted on output. + for json in [r#"{}"#, r#"{"lineInterpolation":null}"#] { + let response: ClickStackLineBuilderChartConfigResponse = + serde_json::from_str(json).unwrap(); + assert_eq!(response.line_interpolation, None); + let response: ClickStackLineRawSqlChartConfigResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.line_interpolation, None); + assert!( + serde_json::to_value(&response) + .unwrap() + .get("lineInterpolation") + .is_none() + ); + } +} + +#[test] +fn clickstack_number_raw_sql_chart_carries_color_rules() { + let config: ClickStackNumberRawSqlChartConfig = serde_json::from_value(serde_json::json!({ + "displayType": "number", "configType": "sql", "connectionId": "c", + "sqlTemplate": "SELECT 1", + "colorRules": [{"operator": "gt", "value": 100.0, "color": "chart-error"}] + })) + .unwrap(); + let rules = config.color_rules.as_deref().unwrap(); + assert!(matches!( + rules, + [ClickStackNumberTileColorCondition::ClickStackNumericColorCondition(_)] + )); + assert_eq!( + serde_json::to_value(&config).unwrap()["colorRules"][0]["operator"], + "gt" + ); + for json in [r#"{}"#, r#"{"colorRules":null}"#] { + let response: ClickStackNumberRawSqlChartConfigResponse = + serde_json::from_str(json).unwrap(); + assert_eq!(response.color_rules, None); + } + let response: ClickStackNumberRawSqlChartConfigResponse = serde_json::from_str( + r#"{"colorRules":[{"operator":"eq","value":"ok","color":"chart-success"}]}"#, + ) + .unwrap(); + assert!(matches!( + response.color_rules.as_deref(), + Some([ClickStackNumberTileColorConditionResponse::ClickStackEqualityColorCondition(_)]) + )); +} + +#[test] +fn clickstack_tiles_carry_a_description() { + let tile: ClickStackTileInput = serde_json::from_value(serde_json::json!({ + "name": "Error Rate", "x": 0, "y": 0, "w": 6, "h": 3, + "description": "Share of API requests that returned a 5xx response." + })) + .unwrap(); + assert_eq!( + tile.description.as_deref(), + Some("Share of API requests that returned a 5xx response.") + ); + let v = serde_json::to_value(ClickStackTileInput::default()).unwrap(); + assert!(v.get("description").is_none()); + + let output: ClickStackTileOutput = + serde_json::from_str(r#"{"id":"t-1","description":"tooltip"}"#).unwrap(); + assert_eq!(output.description.as_deref(), Some("tooltip")); + for json in [r#"{}"#, r#"{"description":null}"#] { + let output: ClickStackTileOutput = serde_json::from_str(json).unwrap(); + assert_eq!(output.description, None); + } +} + +#[test] +fn clickstack_variable_saved_filter_value_carries_all() { + let saved: ClickStackSavedFilterValue = serde_json::from_value(serde_json::json!({ + "type": "variable", "name": "cluster", "values": [], "all": true + })) + .unwrap(); + let ClickStackSavedFilterValue::ClickStackVariableSavedFilterValue(value) = &saved else { + panic!("expected the variable variant, got {saved:?}"); + }; + assert_eq!(value.all, Some(true)); + + for json in [ + r#"{"type":"variable","name":"n","values":[]}"#, + r#"{"type":"variable","name":"n","values":[],"all":null}"#, + ] { + let response: ClickStackVariableSavedFilterValueResponse = + serde_json::from_str(json).unwrap(); + assert_eq!(response.all, None); + } + + // Write-back keeps `all`. + let response: ClickStackVariableSavedFilterValueResponse = + serde_json::from_str(r#"{"type":"variable","name":"n","values":[],"all":true}"#).unwrap(); + let request = ClickStackVariableSavedFilterValue::try_from(response).unwrap(); + assert_eq!(request.all, Some(true)); +} + +#[test] +fn clickstack_time_chart_series_accepts_stacked_line() { + let display: ClickStackTimeChartSeriesDisplaytype = + serde_json::from_str(r#""stacked_line""#).unwrap(); + assert_eq!(display, ClickStackTimeChartSeriesDisplaytype::Stacked_line); + assert_eq!(display.to_string(), "stacked_line"); + assert_eq!( + serde_json::to_string(&display).unwrap(), + r#""stacked_line""# + ); +} + +/// Spec-shaped payloads for every arm of `ClickStackAlertChartConfig`: +/// `(kind, builder, raw SQL)`. Each carries fields only its own variant +/// defines, plus the alert overlay's `name` (and, on builders, `where`). +fn clickstack_alert_chart_config_payloads() +-> [(&'static str, serde_json::Value, serde_json::Value); 4] { + [ + ( + "Line", + serde_json::json!({ + "displayType": "line", "sourceId": "src-1", "select": [{"aggFn": "count"}], + "groupBy": "ServiceName", "lineInterpolation": "step", "fitYAxisToData": true, + "name": "Error Rate Query", "where": "ServiceName:api", "whereLanguage": "lucene" + }), + serde_json::json!({ + "displayType": "line", "configType": "sql", "connectionId": "conn-1", + "sqlTemplate": "SELECT 1", "compareToPreviousPeriod": true, + "name": "Error Rate Query" + }), + ), + ( + "Bar", + serde_json::json!({ + "displayType": "stacked_bar", "sourceId": "src-1", "select": [{"aggFn": "count"}], + "seriesLimit": 5, "name": "Errors by service", "where": "level = 'error'", + "whereLanguage": "sql" + }), + serde_json::json!({ + "displayType": "stacked_bar", "configType": "sql", "connectionId": "conn-1", + "sqlTemplate": "SELECT 1", "fillNulls": true, "name": "Errors by service" + }), + ), + ( + "StackedLine", + serde_json::json!({ + "displayType": "stacked_line", "sourceId": "src-1", "select": [{"aggFn": "count"}], + "lineInterpolation": "linear", "showOperandSeries": false, + "name": "Latency bands", "where": "ServiceName:api", "whereLanguage": "lucene" + }), + serde_json::json!({ + "displayType": "stacked_line", "configType": "sql", "connectionId": "conn-1", + "sqlTemplate": "SELECT 1", "lineInterpolation": "smooth", "name": "Latency bands" + }), + ), + ( + "Number", + serde_json::json!({ + "displayType": "number", "sourceId": "src-1", "select": [{"aggFn": "count"}], + "color": "chart-red", "name": "Error count", "where": "level:error", + "whereLanguage": "lucene" + }), + serde_json::json!({ + "displayType": "number", "configType": "sql", "connectionId": "conn-1", + "sqlTemplate": "SELECT count() FROM otel_logs", "color": "chart-green", + "name": "Error count" + }), + ), + ] +} + +/// The `{outer}/{inner}` arm names a request alert chart config landed in. +fn clickstack_alert_chart_config_arm(config: &ClickStackAlertChartConfig) -> String { + let inner = match config { + ClickStackAlertChartConfig::ClickStackAlertLineChartConfig(c) => c.to_string(), + ClickStackAlertChartConfig::ClickStackAlertBarChartConfig(c) => c.to_string(), + ClickStackAlertChartConfig::ClickStackAlertStackedLineChartConfig(c) => c.to_string(), + ClickStackAlertChartConfig::ClickStackAlertNumberChartConfig(c) => c.to_string(), + ClickStackAlertChartConfig::Unknown(v) => format!("Unknown({v})"), + }; + format!("{config}/{inner}") +} + +/// The `{outer}/{inner}` arm names a response alert chart config landed in. +fn clickstack_alert_chart_config_response_arm( + config: &ClickStackAlertChartConfigResponse, +) -> String { + let inner = match config { + ClickStackAlertChartConfigResponse::ClickStackAlertLineChartConfig(c) => c.to_string(), + ClickStackAlertChartConfigResponse::ClickStackAlertBarChartConfig(c) => c.to_string(), + ClickStackAlertChartConfigResponse::ClickStackAlertStackedLineChartConfig(c) => { + c.to_string() + } + ClickStackAlertChartConfigResponse::ClickStackAlertNumberChartConfig(c) => c.to_string(), + ClickStackAlertChartConfigResponse::Unknown(v) => format!("Unknown({v})"), + }; + format!("{config}/{inner}") +} + +#[test] +fn clickstack_alert_chart_config_routes_each_display_type_and_config_type_to_its_variant() { + for (kind, builder, raw) in clickstack_alert_chart_config_payloads() { + for (payload, variant) in [(builder, "Builder"), (raw, "RawSql")] { + let expected = format!( + "ClickStackAlert{kind}ChartConfig/ClickStackAlert{kind}{variant}ChartConfig" + ); + + let config: ClickStackAlertChartConfig = + serde_json::from_value(payload.clone()).unwrap(); + assert_eq!(clickstack_alert_chart_config_arm(&config), expected); + assert_eq!(serde_json::to_value(&config).unwrap(), payload); + + let response: ClickStackAlertChartConfigResponse = + serde_json::from_value(payload.clone()).unwrap(); + assert_eq!( + clickstack_alert_chart_config_response_arm(&response), + expected + ); + assert_eq!(serde_json::to_value(&response).unwrap(), payload); + } + } + + // The overlay's fields land on the variant structs themselves. + let [(_, line_builder, _), _, _, (_, _, number_raw)] = clickstack_alert_chart_config_payloads(); + let config: ClickStackAlertChartConfig = serde_json::from_value(line_builder).unwrap(); + let ClickStackAlertChartConfig::ClickStackAlertLineChartConfig( + ClickStackAlertLineChartConfig::ClickStackAlertLineBuilderChartConfig(b), + ) = &config + else { + panic!("expected the line builder variant, got {config:?}"); + }; + assert_eq!(b.name.as_deref(), Some("Error Rate Query")); + assert_eq!(b.r#where.as_deref(), Some("ServiceName:api")); + assert_eq!( + b.where_language, + Some(ClickStackAlertChartConfigOverlayWherelanguage::Lucene) + ); + assert_eq!( + b.line_interpolation, + Some(ClickStackLineBuilderChartConfigLineinterpolation::Step) + ); + assert_eq!(b.select[0].agg_fn, ClickStackSelectItemAggfn::Count); + + let response: ClickStackAlertChartConfigResponse = serde_json::from_value(number_raw).unwrap(); + let ClickStackAlertChartConfigResponse::ClickStackAlertNumberChartConfig( + ClickStackAlertNumberChartConfigResponse::ClickStackAlertNumberRawSqlChartConfig(r), + ) = &response + else { + panic!("expected the number raw SQL variant, got {response:?}"); + }; + assert_eq!(r.connection_id.as_deref(), Some("conn-1")); + assert_eq!(r.name.as_deref(), Some("Error count")); + assert_eq!( + r.config_type, + Some(ClickStackNumberRawSqlChartConfigConfigtype::Sql) + ); +} + +#[test] +fn clickstack_alert_chart_config_keeps_payloads_out_of_variants_they_do_not_fit() { + for (kind, builder, raw) in clickstack_alert_chart_config_payloads() { + // A raw-SQL body that lost its `configType` stays out of the builder + // arm, request and response alike, and round-trips verbatim. + let mut unmarked = raw.clone(); + unmarked.as_object_mut().unwrap().remove("configType"); + // An inner `Unknown` displays as its raw JSON. + let expected = format!("ClickStackAlert{kind}ChartConfig/{unmarked}"); + let config: ClickStackAlertChartConfig = serde_json::from_value(unmarked.clone()).unwrap(); + assert_eq!(clickstack_alert_chart_config_arm(&config), expected); + assert_eq!(serde_json::to_value(&config).unwrap(), unmarked); + let response: ClickStackAlertChartConfigResponse = + serde_json::from_value(unmarked.clone()).unwrap(); + assert_eq!( + clickstack_alert_chart_config_response_arm(&response), + expected + ); + assert_eq!(serde_json::to_value(&response).unwrap(), unmarked); + + // The request builder keeps the chart config's required fields. + let mut sourceless = builder.clone(); + sourceless.as_object_mut().unwrap().remove("sourceId"); + let config: ClickStackAlertChartConfig = + serde_json::from_value(sourceless.clone()).unwrap(); + assert_eq!( + clickstack_alert_chart_config_arm(&config), + format!("ClickStackAlert{kind}ChartConfig/{sourceless}") + ); + // The request raw-SQL variant does too. + let mut templateless = raw.clone(); + templateless.as_object_mut().unwrap().remove("sqlTemplate"); + let config: ClickStackAlertChartConfig = + serde_json::from_value(templateless.clone()).unwrap(); + assert_eq!( + clickstack_alert_chart_config_arm(&config), + format!("ClickStackAlert{kind}ChartConfig/{templateless}") + ); + // A response tolerates both, per field. + let response: ClickStackAlertChartConfigResponse = + serde_json::from_value(sourceless).unwrap(); + assert_eq!( + clickstack_alert_chart_config_response_arm(&response), + format!("ClickStackAlert{kind}ChartConfig/ClickStackAlert{kind}BuilderChartConfig") + ); + let response: ClickStackAlertChartConfigResponse = + serde_json::from_value(templateless).unwrap(); + assert_eq!( + clickstack_alert_chart_config_response_arm(&response), + format!("ClickStackAlert{kind}ChartConfig/ClickStackAlert{kind}RawSqlChartConfig") + ); + + // Display types an alert cannot use, or none at all, are Unknown. + for display_type in [Some("pie"), Some("table"), Some("bar"), None] { + let mut other = builder.clone(); + match display_type { + Some(value) => other["displayType"] = serde_json::Value::from(value), + None => { + other.as_object_mut().unwrap().remove("displayType"); + } + } + assert_unknown_variant_round_trips( + &other.to_string(), + |c: &ClickStackAlertChartConfig| { + matches!(c, ClickStackAlertChartConfig::Unknown(_)) + }, + ); + assert_unknown_variant_round_trips( + &other.to_string(), + |c: &ClickStackAlertChartConfigResponse| { + matches!(c, ClickStackAlertChartConfigResponse::Unknown(_)) + }, + ); + } + } + + // A recognized discriminator whose payload changed shape is kept verbatim. + let json = r#"{"displayType":"number","configType":"sql","sqlTemplate":5}"#; + assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChartConfigResponse| { + matches!( + c, + ClickStackAlertChartConfigResponse::ClickStackAlertNumberChartConfig( + ClickStackAlertNumberChartConfigResponse::Unknown(_) + ) + ) + }); +} + +#[test] +fn clickstack_alert_chart_config_responses_treat_dropped_and_null_fields_as_absent() { + macro_rules! assert_absent_and_null_are_none { + ($($response:ty),+ $(,)?) => { + $( + for wire in [ + "{}", + r#"{"name":null,"where":null,"whereLanguage":null,"sourceId":null,"numberFormat":null}"#, + ] { + let parsed: $response = serde_json::from_str(wire).unwrap(); + assert_eq!(parsed, <$response>::default(), "{} from {wire}", stringify!($response)); + assert_eq!(serde_json::to_value(&parsed).unwrap(), serde_json::json!({})); + } + )+ + }; + } + assert_absent_and_null_are_none!( + ClickStackAlertBarBuilderChartConfigResponse, + ClickStackAlertBarRawSqlChartConfigResponse, + ClickStackAlertLineBuilderChartConfigResponse, + ClickStackAlertLineRawSqlChartConfigResponse, + ClickStackAlertNumberBuilderChartConfigResponse, + ClickStackAlertNumberRawSqlChartConfigResponse, + ClickStackAlertStackedLineBuilderChartConfigResponse, + ClickStackAlertStackedLineRawSqlChartConfigResponse, + ); + + let response: ClickStackAlertChartConfigResponse = + serde_json::from_str(r#"{"displayType":"stacked_line","name":null,"select":null}"#) + .unwrap(); + assert_eq!( + response, + ClickStackAlertChartConfigResponse::ClickStackAlertStackedLineChartConfig( + ClickStackAlertStackedLineChartConfigResponse::ClickStackAlertStackedLineBuilderChartConfig( + ClickStackAlertStackedLineBuilderChartConfigResponse { + display_type: Some( + ClickStackStackedLineBuilderChartConfigDisplaytype::Stacked_line + ), + ..Default::default() + } + ) + ) + ); +} + +#[test] +fn clickstack_alert_chart_config_overlay_fields_are_all_optional() { + let overlay: ClickStackAlertChartConfigOverlay = serde_json::from_str("{}").unwrap(); + assert_eq!(overlay, ClickStackAlertChartConfigOverlay::default()); + let wire = serde_json::json!({"name": "Q", "where": "a:b", "whereLanguage": "sql"}); + let overlay: ClickStackAlertChartConfigOverlay = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + overlay.where_language, + Some(ClickStackAlertChartConfigOverlayWherelanguage::Sql) + ); + assert_eq!(serde_json::to_value(&overlay).unwrap(), wire); + let language: ClickStackAlertChartConfigOverlayWherelanguage = + serde_json::from_str(r#""kql""#).unwrap(); + assert_eq!( + language, + ClickStackAlertChartConfigOverlayWherelanguage::Unknown("kql".into()) + ); +} + +#[test] +fn clickstack_alert_sources_accept_inline() { + assert_eq!( + serde_json::from_str::(r#""inline""#).unwrap(), + ClickStackAlertResponseSource::Inline + ); + assert_eq!( + serde_json::from_str::(r#""inline""#).unwrap(), + ClickStackCreateAlertRequestSource::Inline + ); + assert_eq!( + serde_json::from_str::(r#""inline""#).unwrap(), + ClickStackUpdateAlertRequestSource::Inline + ); + assert_eq!(ClickStackAlertResponseSource::Inline.to_string(), "inline"); + assert_eq!( + ClickStackCreateAlertRequestSource::Inline.to_string(), + "inline" + ); + assert_eq!( + ClickStackUpdateAlertRequestSource::Inline.to_string(), + "inline" + ); +} + +#[test] +fn clickstack_alert_requests_send_inline_chart_configs_display_names_and_tags() { + let [(_, builder, _), ..] = clickstack_alert_chart_config_payloads(); + let wire = serde_json::json!({ + "source": "inline", "chartConfig": builder, "threshold": 10.0, "interval": "5m", + "thresholdType": "above", "displayName": "Checkout error spike", + "tags": ["checkout", "p1"] + }); + + let create: ClickStackCreateAlertRequest = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + create.source, + Some(ClickStackCreateAlertRequestSource::Inline) + ); + assert!(matches!( + create.chart_config, + Some(ClickStackAlertChartConfig::ClickStackAlertLineChartConfig( + ClickStackAlertLineChartConfig::ClickStackAlertLineBuilderChartConfig(_) + )) + )); + assert_eq!(create.display_name.as_deref(), Some("Checkout error spike")); + assert_eq!( + create.tags.as_deref(), + Some(&["checkout".into(), "p1".into()][..]) + ); + assert_eq!( + (create.channel.as_ref(), create.channels.as_ref()), + (None, None) + ); + assert_eq!(serde_json::to_value(&create).unwrap(), wire); + + let update: ClickStackUpdateAlertRequest = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + update.source, + Some(ClickStackUpdateAlertRequestSource::Inline) + ); + assert_eq!(update.display_name.as_deref(), Some("Checkout error spike")); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); +} + +#[test] +fn clickstack_alert_requests_omit_unset_source_and_channels_but_stay_strict() { + // `source`, `channel` and `channels` are no longer required, so an unset + // value is omitted from the wire rather than defaulted. + let create = serde_json::to_value(ClickStackCreateAlertRequest::default()).unwrap(); + let update = serde_json::to_value(ClickStackUpdateAlertRequest::default()).unwrap(); + for wire in [&create, &update] { + for key in [ + "source", + "channel", + "channels", + "chartConfig", + "displayName", + "tags", + ] { + assert!(wire.get(key).is_none(), "{key} was sent unset: {wire}"); + } + } + + // `threshold`, `interval` and `thresholdType` stay required. + let minimal = serde_json::json!({"threshold": 1.0, "interval": "1m", "thresholdType": "above"}); + let parsed: ClickStackCreateAlertRequest = serde_json::from_value(minimal.clone()).unwrap(); + assert_eq!(serde_json::to_value(&parsed).unwrap(), minimal); + let parsed: ClickStackUpdateAlertRequest = serde_json::from_value(minimal.clone()).unwrap(); + assert_eq!(serde_json::to_value(&parsed).unwrap(), minimal); + for key in ["threshold", "interval", "thresholdType"] { + let mut missing = minimal.clone(); + missing.as_object_mut().unwrap().remove(key); + assert!(serde_json::from_value::(missing.clone()).is_err()); + assert!(serde_json::from_value::(missing).is_err()); + } +} + +#[test] +fn clickstack_alert_response_carries_inline_chart_config_display_name_and_tags() { + let [_, (_, _, bar_raw), ..] = clickstack_alert_chart_config_payloads(); + let alert: ClickStackAlertResponse = serde_json::from_value(serde_json::json!({ + "id": "alert-1", "source": "inline", "chartConfig": bar_raw, + "displayName": "Errors by service", "tags": [], "state": "OK" + })) + .unwrap(); + assert_eq!(alert.source, Some(ClickStackAlertResponseSource::Inline)); + assert_eq!(alert.display_name.as_deref(), Some("Errors by service")); + assert_eq!(alert.tags, Some(vec![])); + assert!(matches!( + alert.chart_config, + Some( + ClickStackAlertChartConfigResponse::ClickStackAlertBarChartConfig( + ClickStackAlertBarChartConfigResponse::ClickStackAlertBarRawSqlChartConfig(_) + ) + ) + )); + + // The spec requires displayName and tags on the response, but a response + // that drops them or sends `null` still deserializes, to `None`. + let alert: ClickStackAlertResponse = serde_json::from_str( + r#"{"id":"alert-1","chartConfig":null,"displayName":null,"tags":null}"#, + ) + .unwrap(); + assert_eq!(alert.chart_config, None); + assert_eq!(alert.display_name, None); + assert_eq!(alert.tags, None); + assert_eq!( + serde_json::to_value(&alert).unwrap(), + serde_json::json!({"id": "alert-1"}) + ); +} diff --git a/crates/clickhouse-cloud-api/tests/models/cross_domain.rs b/crates/clickhouse-cloud-api/tests/models/cross_domain.rs new file mode 100644 index 00000000..08767247 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/cross_domain.rs @@ -0,0 +1,328 @@ +use clickhouse_cloud_api::models::*; + +use crate::assert_unknown_variant_round_trips; + +/// Every `discriminated_union!` enum with a `Default` is reachable from a +/// response that drops a field carrying that union, so its default must be a +/// fixed point of its own `Deserialize`: it has to come back as the same +/// variant. It is not automatic — variants of one union can share the same +/// inline `enum` values for the discriminating field (both alert channels +/// declare `["webhook", "email"]`), so a variant defaulting its discriminator to +/// another variant's value would silently retype the value on the next +/// deserialize. The covered list is enforced structurally, not by convention: +/// it must equal the set of hand-written `impl Default for` blocks in the model +/// module tree (via the analyzer's `model_types_with_manual_default_impl`), so a +/// new union gaining a `Default` without a list entry fails this test. +#[test] +fn discriminated_union_defaults_round_trip_to_the_same_variant() { + let mut covered: Vec<&str> = Vec::new(); + + macro_rules! assert_default_round_trips { + ($($union:ty),+ $(,)?) => { + $({ + covered.push(stringify!($union)); + let default = <$union>::default(); + let json = serde_json::to_string(&default).unwrap(); + let parsed: $union = serde_json::from_str(&json).unwrap(); + assert_eq!( + parsed, + default, + "{} default deserialized as another variant from {json}", + stringify!($union), + ); + })+ + }; + } + + assert_default_round_trips!( + BackupBucket, + BackupBucketPatchRequest, + BackupBucketPostRequest, + BackupBucketProperties, + ClickPipeBigQuerySource, + ClickPipeKafkaSchemaRegistry, + ClickPipeMutateBigQuerySource, + ClickPipeMutateKafkaSchemaRegistry, + ClickPipePostPubSubSource, + ClickStackSavedFilterValue, + ClickStackSavedFilterValueResponse, + ClickStackAlertBarChartConfig, + ClickStackAlertChannel, + ClickStackAlertChartConfig, + ClickStackAlertLineChartConfig, + ClickStackAlertNumberChartConfig, + ClickStackAlertStackedLineChartConfig, + ClickStackBarChartConfig, + ClickStackCategoricalBarChartConfig, + ClickStackDashboardChartSeries, + ClickStackFilter, + ClickStackFilterInput, + ClickStackHeatmapChartConfig, + ClickStackLineChartConfig, + ClickStackNumberChartConfig, + ClickStackOnClick, + ClickStackOnClickTarget, + ClickStackPieChartConfig, + ClickStackSource, + ClickStackStackedLineChartConfig, + ClickStackTableChartConfig, + ClickStackTileConfig, + ClickStackWebhook, + ); + + covered.sort_unstable(); + let manual_default_impls = clickhouse_openapi_analyzer::model_types_with_manual_default_impl( + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")), + ) + .unwrap(); + assert_eq!( + covered, + manual_default_impls + .iter() + .map(String::as_str) + .collect::>(), + "the covered list must equal the manual `impl Default for` blocks in the model tree" + ); +} + +/// In the default build the deprecated request fields don't exist on the +/// struct, so callers can't set them and they never reach the wire. +#[cfg(not(feature = "deprecated-fields"))] +#[test] +fn deprecated_request_fields_absent_by_default() { + let member = MemberPatchRequest { + assigned_role_ids: Some(vec!["admin".to_string()]), + }; + assert!(serde_json::to_value(&member).unwrap().get("role").is_none()); + + let invitation = InvitationPostRequest { + email: "alice@example.com".to_string(), + assigned_role_ids: Some(vec!["admin".to_string()]), + }; + assert!( + serde_json::to_value(&invitation) + .unwrap() + .get("role") + .is_none() + ); + + let scaling = ServiceScalingPatchRequest { + num_replicas: Some(3), + ..Default::default() + }; + let scaling = serde_json::to_value(&scaling).unwrap(); + assert!(scaling.get("minTotalMemoryGb").is_none()); + assert!(scaling.get("maxTotalMemoryGb").is_none()); +} + +/// In the default build (no `deprecated-fields` feature) deprecated response +/// fields don't exist on the struct, so they can't be read and never appear in +/// serialized output. Deserializing a payload that contains them simply ignores +/// the extra keys. +#[cfg(not(feature = "deprecated-fields"))] +#[test] +fn deprecated_fields_absent_by_default() { + let svc: Service = serde_json::from_str( + r#"{"name":"svc","tier":"production","minTotalMemoryGb":24,"maxTotalMemoryGb":48}"#, + ) + .unwrap(); + let v = serde_json::to_value(&svc).unwrap(); + assert!(v.get("tier").is_none()); + assert!(v.get("minTotalMemoryGb").is_none()); + assert!(v.get("maxTotalMemoryGb").is_none()); + + let m: Member = serde_json::from_str(r#"{"name":"Alice","role":"admin"}"#).unwrap(); + assert!(serde_json::to_value(&m).unwrap().get("role").is_none()); +} + +#[test] +fn infrastructure_responses_tolerate_dropped_and_null_fields() { + // Reverse private endpoints and quotas are response-only, so every field is + // `Option`: a dropped key and an explicit `null` both land as `None`. + // `null` is the case `#[serde(default)]` never covered. + let dropped: ReversePrivateEndpoint = serde_json::from_str("{}").unwrap(); + let nulled: ReversePrivateEndpoint = serde_json::from_str( + r#"{"id":null,"description":null,"status":null,"type":null,"dnsNames":null, + "privateDnsNames":null,"endpointId":null,"serviceId":null, + "customPrivateDnsMappings":null,"dnsTargets":null, + "privateDnsMappings":null}"#, + ) + .unwrap(); + assert_eq!(dropped, ReversePrivateEndpoint::default()); + assert_eq!(nulled, ReversePrivateEndpoint::default()); + // A `null` on a list field is the residual case the previous + // `#[serde(default)]` policy still failed on. + assert_eq!(nulled.dns_names, None); + assert_eq!(nulled.custom_private_dns_mappings, None); + assert_eq!(nulled.dns_targets, None); + assert_eq!(nulled.private_dns_mappings, None); + + let quota: OrganizationQuota = serde_json::from_str(r#"{"name":"Services"}"#).unwrap(); + assert_eq!(quota.name.as_deref(), Some("Services")); + assert_eq!(quota.quota_code, None); + assert_eq!(quota.value, None); + assert_eq!(quota.adjustable, None); +} + +#[test] +fn infrastructure_shared_leaves_stay_strict_on_the_request_side() { + // `customPrivateDnsMapping` and `RBACPolicyTags` are sent as well as + // returned, so each splits: only the `{Name}Response` variant is used by a + // response type, and neither variant fabricates a value for a missing key. + let mapping = CustomPrivateDnsMapping { + private_dns_name: Some("db.internal".to_string()), + target_id: None, + }; + assert_eq!( + serde_json::to_value(&mapping).unwrap(), + serde_json::json!({ "privateDnsName": "db.internal" }) + ); + let targeted = CustomPrivateDnsMapping { + private_dns_name: Some("db.internal".to_string()), + target_id: Some("rcfg-097648d8068504966".to_string()), + }; + assert_eq!( + serde_json::to_value(&targeted).unwrap(), + serde_json::json!({ + "privateDnsName": "db.internal", + "targetId": "rcfg-097648d8068504966", + }) + ); + // Both schemas are all-optional upstream, so the request variants accept an + // empty object too — what the split guarantees is that the response variant + // can never regain a required field. + assert_eq!( + serde_json::from_str::("{}").unwrap(), + CustomPrivateDnsMappingResponse::default() + ); + assert_eq!( + serde_json::from_str::(r#"{"grants":null,"roleV2":null}"#).unwrap(), + RBACPolicyTagsResponse::default() + ); + let policy: RBACPolicy = serde_json::from_str(r#"{"tags":{"grants":["select"]}}"#).unwrap(); + assert_eq!( + policy.tags, + Some(RBACPolicyTagsResponse { + grants: Some(vec!["select".to_string()]), + role_v2: None, + }) + ); +} + +#[test] +fn new_response_models_tolerate_absent_and_null_fields() { + let balances: ActiveBalances = + serde_json::from_str(r#"{"totalRemainingPrepaidCredits":null,"prepaidBalances":null}"#) + .unwrap(); + let balance: ActiveBalance = serde_json::from_str( + r#"{"id":null,"remainingPrepaidCredits":null,"totalAmount":null,"amountSpent":null,"startDate":null,"expirationDate":null}"#, + ) + .unwrap(); + let labels: PrometheusDiscoveryLabels = serde_json::from_str( + r#"{"__scheme__":null,"__metrics_path__":null,"__param_filtered_metrics":null,"clickhouse_org_id":null,"clickhouse_service_id":null,"clickhouse_discovery_service_name":null}"#, + ) + .unwrap(); + let group: PrometheusDiscoveryTargetGroup = + serde_json::from_str(r#"{"targets":null,"labels":null}"#).unwrap(); + let log: PostgresLogEntry = + serde_json::from_str(r#"{"timestamp":null,"severity":null,"body":null}"#).unwrap(); + + assert_eq!(balances, ActiveBalances::default()); + assert_eq!(balance, ActiveBalance::default()); + assert_eq!(labels, PrometheusDiscoveryLabels::default()); + assert_eq!(group, PrometheusDiscoveryTargetGroup::default()); + assert_eq!(log, PostgresLogEntry::default()); + assert_eq!( + serde_json::to_value(balances).unwrap(), + serde_json::json!({}) + ); + assert_eq!(serde_json::to_value(log).unwrap(), serde_json::json!({})); +} + +#[test] +fn new_response_models_preserve_missing_and_null_fields() { + macro_rules! tolerant { + ($model:ty, [$($field:literal),+]) => {{ + let empty: $model = serde_json::from_value(serde_json::json!({})).unwrap(); + let nulls: $model = serde_json::from_value(serde_json::json!({$($field: null),+})).unwrap(); + assert_eq!(empty, nulls); + assert_eq!(serde_json::to_value(empty).unwrap(), serde_json::json!({})); + }}; + } + tolerant!( + CreditBalance, + [ + "id", + "type", + "remainingCredits", + "totalAmount", + "amountSpent", + "startDate", + "expirationDate" + ] + ); + tolerant!(CreditBalances, ["totalRemainingCredits", "balances"]); + tolerant!(ServiceProfile, ["profile", "cpuCores", "memoryGi"]); + tolerant!( + ClickPipeBigQueryServiceAccountSource, + [ + "snapshotStagingPath", + "settings", + "tableMappings", + "authentication", + "projectId" + ] + ); + tolerant!( + ClickPipeBigQueryWorkloadIdentitySource, + [ + "snapshotStagingPath", + "settings", + "tableMappings", + "authentication", + "projectId" + ] + ); + tolerant!( + ClickPipesGcpWorkloadIdentityContext, + ["supported", "ready", "principal"] + ); + tolerant!(ClickPipesServiceContext, ["gcpWorkloadIdentity"]); + tolerant!( + ClickStackFormulaResponse, + ["expression", "alias", "numberFormat"] + ); + tolerant!(ClickStackSqlSavedFilterValueResponse, ["type", "condition"]); + tolerant!( + ClickStackVariableSavedFilterValueResponse, + ["type", "name", "values"] + ); +} + +#[test] +fn new_source_and_saved_filter_unions_preserve_unknown_payloads() { + assert_unknown_variant_round_trips::( + r#"{"authentication":"FUTURE","newField":1}"#, + |v| matches!(v, ClickPipeMutateBigQuerySource::Unknown(_)), + ); + assert_unknown_variant_round_trips::( + r#"{"authentication":"FUTURE","newField":1}"#, + |v| matches!(v, ClickPipeBigQuerySource::Unknown(_)), + ); + assert_unknown_variant_round_trips::( + r#"{"authentication":"FUTURE","newField":1}"#, + |v| matches!(v, ClickPipePostPubSubSource::Unknown(_)), + ); + assert_unknown_variant_round_trips::( + r#"{"type":"future","values":["keep"]}"#, + |v| matches!(v, ClickStackSavedFilterValueResponse::Unknown(_)), + ); + assert_unknown_variant_round_trips::( + r#"{"name":"service","values":["keep"]}"#, + |v| matches!(v, ClickStackSavedFilterValueResponse::Unknown(_)), + ); + assert_unknown_variant_round_trips::( + r#"{"authentication":"SERVICE_ACCOUNT","settings":"new-shape"}"#, + |v| matches!(v, ClickPipeBigQuerySource::Unknown(_)), + ); +} diff --git a/crates/clickhouse-cloud-api/tests/models/main.rs b/crates/clickhouse-cloud-api/tests/models/main.rs new file mode 100644 index 00000000..eb6c5749 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/main.rs @@ -0,0 +1,35 @@ +mod activity; +mod api_keys; +mod backups; +mod byoc; +mod clickpipes; +mod clickstack; +mod cross_domain; +mod organizations; +mod postgres; +mod query_api_endpoints; +mod saved_queries; +mod scim; +mod services; +mod shared; +mod udfs; +mod whoami; + +/// Shared assertion for the discriminated-union `Unknown` catch-all: an +/// unrecognized payload must deserialize into the union's lossless `Unknown` +/// variant (confirmed by `is_unknown`) and re-serialize to the byte-identical +/// JSON object it came from. +fn assert_unknown_variant_round_trips(json: &str, is_unknown: impl FnOnce(&T) -> bool) +where + T: serde::Serialize + serde::de::DeserializeOwned, +{ + let original: serde_json::Value = serde_json::from_str(json).unwrap(); + let parsed: T = serde_json::from_str(json).unwrap(); + assert!( + is_unknown(&parsed), + "payload did not deserialize to the Unknown variant" + ); + let reserialized = serde_json::to_value(&parsed).unwrap(); + assert_eq!(reserialized, original); + assert!(reserialized.is_object()); +} diff --git a/crates/clickhouse-cloud-api/tests/models/organizations.rs b/crates/clickhouse-cloud-api/tests/models/organizations.rs new file mode 100644 index 00000000..845a8e91 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/organizations.rs @@ -0,0 +1,388 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn deserialize_organization() { + let json = r#"{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "My Organization", + "createdAt": "2024-01-15T10:30:00Z", + "privateEndpoints": [], + "byocConfig": [], + "enableCoreDumps": false + }"#; + let org: Organization = serde_json::from_str(json).unwrap(); + assert_eq!(org.name.as_deref(), Some("My Organization")); + assert_eq!( + org.id, + Some( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + .parse::() + .unwrap() + ) + ); + assert_eq!(org.enable_core_dumps, Some(false)); +} + +#[test] +fn serialize_organization() { + let org = Organization { + id: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".parse().unwrap()), + name: Some("Test Org".to_string()), + ..Default::default() + }; + let json = serde_json::to_value(&org).unwrap(); + assert_eq!(json["name"], "Test Org"); + assert_eq!(json["id"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + // Absent response fields are omitted, not emitted as `null`. + assert!(json.get("createdAt").is_none()); + assert!(json.get("enableCoreDumps").is_none()); +} + +#[test] +fn deserialize_member() { + let json = r#"{ + "userId": "user-123", + "name": "John Doe", + "email": "john@example.com", + "role": "admin", + "joinedAt": "2024-01-01T00:00:00Z" + }"#; + let member: Member = serde_json::from_str(json).unwrap(); + assert_eq!(member.name.as_deref(), Some("John Doe")); + assert_eq!(member.email.as_deref(), Some("john@example.com")); + #[cfg(feature = "deprecated-fields")] + assert_eq!(member.role, Some(MemberRole::Admin)); +} + +#[test] +fn deserialize_invitation() { + let json = r#"{ + "id": "33333333-4444-5555-6666-777777777777", + "email": "new@example.com", + "role": "developer", + "createdAt": "2024-06-01T00:00:00Z" + }"#; + let inv: Invitation = serde_json::from_str(json).unwrap(); + assert_eq!(inv.email.as_deref(), Some("new@example.com")); + #[cfg(feature = "deprecated-fields")] + assert_eq!(inv.role, Some(InvitationRole::Developer)); +} + +#[test] +fn absent_response_fields_are_omitted_when_serialized() { + let org = Organization { + name: Some("Test".to_string()), + ..Default::default() + }; + let json = serde_json::to_value(&org).unwrap(); + // Absent means absent: a field the API did not return is omitted from + // `--json` output rather than emitted as `null`. + assert_eq!(json, serde_json::json!({ "name": "Test" })); +} + +#[test] +fn serialize_invitation_post_request() { + let req = InvitationPostRequest { + email: "alice@example.com".to_string(), + #[cfg(feature = "deprecated-fields")] + role: Some(InvitationPostRequestRole::Developer), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["email"], "alice@example.com"); + #[cfg(feature = "deprecated-fields")] + assert_eq!(json["role"], "developer"); + // By default the deprecated `role` field is gated out and never serialized. + #[cfg(not(feature = "deprecated-fields"))] + assert!(json.get("role").is_none()); +} + +#[cfg(feature = "deprecated-fields")] +#[test] +fn serialize_member_patch_request() { + let req = MemberPatchRequest { + role: Some(MemberPatchRequestRole::Admin), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["role"], "admin"); +} + +#[test] +fn organization_ignores_extra_fields() { + let json = r#"{"name":"Test","brandNewField":"surprise","anotherNew":42}"#; + let org: Organization = serde_json::from_str(json).unwrap(); + assert_eq!(org.name.as_deref(), Some("Test")); +} + +#[test] +fn member_ignores_extra_fields() { + let json = r#"{"name":"Alice","role":"admin","department":"eng","mfa":true}"#; + let m: Member = serde_json::from_str(json).unwrap(); + assert_eq!(m.name.as_deref(), Some("Alice")); + #[cfg(feature = "deprecated-fields")] + assert_eq!(m.role, Some(MemberRole::Admin)); +} + +#[test] +fn invitation_ignores_extra_fields() { + let json = r#"{"email":"a@b.com","role":"developer","expiresIn":"7d"}"#; + let inv: Invitation = serde_json::from_str(json).unwrap(); + assert_eq!(inv.email.as_deref(), Some("a@b.com")); +} + +#[test] +fn organization_minimal_response() { + let org: Organization = serde_json::from_str(r#"{"name":"X"}"#).unwrap(); + assert_eq!(org.name.as_deref(), Some("X")); + assert_eq!(org.id, None); + assert_eq!(org.created_at, None); +} + +#[test] +fn organization_quota_typed_enums_round_trip() { + let json = r#"{ + "quotaCode": "replicas-per-warehouse", + "name": "Replicas per warehouse", + "description": "Limits each warehouse individually.", + "scope": "warehouse", + "value": 20, + "usage": 3, + "adjustable": true + }"#; + let quota: OrganizationQuota = serde_json::from_str(json).unwrap(); + assert_eq!( + quota.quota_code, + Some(OrganizationQuotaQuotacode::Replicas_per_warehouse) + ); + assert_eq!(quota.scope, Some(OrganizationQuotaScope::Warehouse)); + assert_eq!(quota.value, Some(20)); + assert_eq!(quota.usage, Some(3)); + assert_eq!(quota.adjustable, Some(true)); + + let v = serde_json::to_value("a).unwrap(); + assert_eq!(v["quotaCode"], "replicas-per-warehouse"); + assert_eq!(v["scope"], "warehouse"); + assert_eq!(v["value"], 20); + assert_eq!(v["usage"], 3); + + let back: OrganizationQuota = serde_json::from_value(v).unwrap(); + assert_eq!(back, quota); +} + +#[test] +fn organization_api_key_quota_code_round_trips() { + let parsed: OrganizationQuotaQuotacode = + serde_json::from_str("\"api-keys-per-organization\"").unwrap(); + assert_eq!( + parsed, + OrganizationQuotaQuotacode::Api_keys_per_organization + ); + assert_eq!(parsed.to_string(), "api-keys-per-organization"); + assert_eq!( + serde_json::to_value(parsed).unwrap(), + "api-keys-per-organization" + ); +} + +#[test] +fn organization_quota_usage_optional_omitted() { + let json = r#"{ + "quotaCode": "services-per-organization", + "name": "Services per organization", + "description": "Limits services.", + "scope": "organization", + "value": 20, + "adjustable": false + }"#; + let quota: OrganizationQuota = serde_json::from_str(json).unwrap(); + assert_eq!( + quota.quota_code, + Some(OrganizationQuotaQuotacode::Services_per_organization) + ); + assert_eq!(quota.scope, Some(OrganizationQuotaScope::Organization)); + assert_eq!(quota.usage, None); + + let v = serde_json::to_value("a).unwrap(); + assert!(v.get("usage").is_none(), "usage must be omitted when None"); +} + +#[test] +fn organization_quota_tolerates_explicit_null_fields() { + // `OrganizationQuota` is response-only, so every field is `Option` and an + // explicit `null` lands as `None` exactly like a dropped key — the case the + // superseded `#[serde(default)]` policy never covered. + let quota: OrganizationQuota = serde_json::from_str( + r#"{"quotaCode":null,"name":null,"description":null,"scope":null, + "value":null,"usage":null,"adjustable":null}"#, + ) + .unwrap(); + assert_eq!(quota, OrganizationQuota::default()); + + // Absence is omitted on the way out, never re-emitted as `null`. + assert_eq!( + serde_json::to_value("a).unwrap(), + serde_json::json!({}), + "absent response fields must be omitted from --json output" + ); +} + +#[test] +fn organization_quota_quota_code_unknown_catch_all() { + let parsed: OrganizationQuotaQuotacode = + serde_json::from_str("\"queries-per-second\"").unwrap(); + assert_eq!( + parsed, + OrganizationQuotaQuotacode::Unknown("queries-per-second".to_string()) + ); + assert_eq!(parsed.to_string(), "queries-per-second"); + assert_eq!(serde_json::to_value(&parsed).unwrap(), "queries-per-second"); +} + +#[test] +fn role_update_request_omits_fields_that_are_not_being_changed() { + let request = RoleUpdateRequest { + name: Some("auditor".to_string()), + actors: None, + policies: None, + }; + + assert_eq!( + serde_json::to_value(request).unwrap(), + serde_json::json!({"name": "auditor"}) + ); +} + +#[test] +fn organization_capabilities_tolerate_absence_null_and_false() { + for wire in [ + serde_json::json!({}), + serde_json::json!({"capabilities": null}), + ] { + let org: Organization = serde_json::from_value(wire).unwrap(); + assert_eq!(org.capabilities, None); + assert_eq!(serde_json::to_value(org).unwrap(), serde_json::json!({})); + } + for wire in [ + serde_json::json!({}), + serde_json::json!({"snapshots": null}), + ] { + let capabilities: OrganizationCapabilities = serde_json::from_value(wire).unwrap(); + assert_eq!(capabilities.snapshots, None); + assert_eq!( + serde_json::to_value(capabilities).unwrap(), + serde_json::json!({}) + ); + } + let org: Organization = serde_json::from_value(serde_json::json!({ + "capabilities": {"snapshots": false} + })) + .unwrap(); + assert_eq!(org.capabilities.unwrap().snapshots, Some(false)); +} + +#[test] +fn notification_type_missing_and_null_fields_are_none() { + let missing: NotificationType = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, NotificationType::default()); + assert_eq!( + serde_json::to_value(&missing).unwrap(), + serde_json::json!({}) + ); + + let null: NotificationType = serde_json::from_value(serde_json::json!({ + "name": null, "title": null, "description": null, "category": null, + "severity": null, "resourceType": null, "isConfigurable": null, + "isAvailable": null, "futureField": 1 + })) + .unwrap(); + assert_eq!(null, NotificationType::default()); +} + +#[test] +fn notification_type_severity_round_trips_and_preserves_unknown() { + for (wire, value) in [ + ("info", NotificationTypeSeverity::Info), + ("warning", NotificationTypeSeverity::Warning), + ("critical", NotificationTypeSeverity::Critical), + ] { + let parsed: NotificationTypeSeverity = + serde_json::from_value(serde_json::json!(wire)).unwrap(); + assert_eq!(parsed, value); + assert_eq!(value.to_string(), wire); + assert_eq!( + serde_json::to_value(&value).unwrap(), + serde_json::json!(wire) + ); + } + let unknown: NotificationTypeSeverity = + serde_json::from_value(serde_json::json!("emergency")).unwrap(); + assert_eq!( + unknown, + NotificationTypeSeverity::Unknown("emergency".to_string()) + ); + assert_eq!(unknown.to_string(), "emergency"); +} + +#[test] +fn prometheus_discovery_labels_keep_named_meta_and_additional_labels() { + let json = serde_json::json!({ + "__scheme__": "https", + "__meta_clickhouse_provider": "aws", + "__meta_clickhouse_region": "us-east-1", + "clickhouse_discovery_service_name": "analytics", + "clickhouse_custom_label": "blue" + }); + // An unknown non-string key is ignored rather than failing the response. + let mut with_unknown = json.clone(); + with_unknown["future_flag"] = serde_json::json!(true); + let tolerant: PrometheusDiscoveryLabels = serde_json::from_value(with_unknown).unwrap(); + assert_eq!(serde_json::to_value(&tolerant).unwrap(), json); + + let labels: PrometheusDiscoveryLabels = serde_json::from_value(json.clone()).unwrap(); + + assert_eq!(labels.meta_clickhouse_provider.as_deref(), Some("aws")); + assert_eq!(labels.meta_clickhouse_region.as_deref(), Some("us-east-1")); + assert_eq!( + labels.additional_labels, + Some(std::collections::BTreeMap::from([( + "clickhouse_custom_label".to_string(), + "blue".to_string() + )])) + ); + // Named labels are not duplicated into the additional map, and the + // round trip is lossless. + assert_eq!(serde_json::to_value(&labels).unwrap(), json); +} + +#[test] +fn prometheus_discovery_labels_without_additional_labels_serialize_compactly() { + let labels: PrometheusDiscoveryLabels = + serde_json::from_str(r#"{"__meta_clickhouse_region":null}"#).unwrap(); + assert_eq!(labels.meta_clickhouse_region, None); + assert_eq!( + serde_json::to_value(&labels).unwrap(), + serde_json::json!({}) + ); +} + +#[test] +fn notification_tolerates_missing_null_and_future_values() { + let missing: Notification = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, Notification::default()); + let null: Notification = serde_json::from_value(serde_json::json!({ + "id": null, "type": null, "severity": null, "resourceType": null, + "resourceId": null, "resourceName": null, "createdAt": null, + "title": null, "description": null, "future": true + })) + .unwrap(); + assert_eq!(null, missing); + assert_eq!(serde_json::to_value(null).unwrap(), serde_json::json!({})); + let future: Notification = serde_json::from_value(serde_json::json!({ + "type": "future.type", "severity": "future.severity", "resourceType": "future.resource" + })) + .unwrap(); + assert_eq!(future.r#type.as_deref(), Some("future.type")); + assert_eq!(future.severity.as_deref(), Some("future.severity")); + assert_eq!(future.resource_type.as_deref(), Some("future.resource")); +} diff --git a/crates/clickhouse-cloud-api/tests/models/postgres.rs b/crates/clickhouse-cloud-api/tests/models/postgres.rs new file mode 100644 index 00000000..97064e40 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/postgres.rs @@ -0,0 +1,929 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn deserialize_postgres_service() { + let json = r#"{ + "id": "44444444-5555-6666-7777-888888888888", + "name": "my-postgres", + "provider": "aws", + "region": "us-east-1", + "state": "running" + }"#; + let pg: PostgresService = serde_json::from_str(json).unwrap(); + assert_eq!(pg.name.as_deref(), Some("my-postgres")); +} + +#[test] +fn postgres_gcp_provider_roundtrips() { + let provider: PgProvider = serde_json::from_str(r#""gcp""#).unwrap(); + assert_eq!(provider, PgProvider::Gcp); + assert_eq!(provider.to_string(), "gcp"); + assert_eq!(serde_json::to_string(&provider).unwrap(), r#""gcp""#); + assert_eq!(PgProvider::VALUES, &["aws", "gcp"]); +} + +#[test] +fn postgres_config_enum_values_roundtrip_and_match_values() { + for value in PgConfigDefaultTransactionIsolation::VALUES { + let parsed: PgConfigDefaultTransactionIsolation = + serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!(parsed, PgConfigDefaultTransactionIsolation::Unknown(_)), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + } + for value in PgConfigSslMinProtocolVersion::VALUES { + let parsed: PgConfigSslMinProtocolVersion = + serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!(parsed, PgConfigSslMinProtocolVersion::Unknown(_)), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + } + for value in PgConfigWalCompression::VALUES { + let parsed: PgConfigWalCompression = + serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!(parsed, PgConfigWalCompression::Unknown(_)), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + } + for value in PgConfigDefaultToastCompression::VALUES { + let parsed: PgConfigDefaultToastCompression = + serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!(parsed, PgConfigDefaultToastCompression::Unknown(_)), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + } + for value in PgConfigEnableParallelHash::VALUES { + let parsed: PgConfigEnableParallelHash = + serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!(parsed, PgConfigEnableParallelHash::Unknown(_)), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + } + for value in PgConfigIoMethod::VALUES { + let parsed: PgConfigIoMethod = serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!(parsed, PgConfigIoMethod::Unknown(_)), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + } + for value in PgConfigLogLockWaits::VALUES { + let parsed: PgConfigLogLockWaits = serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!(parsed, PgConfigLogLockWaits::Unknown(_)), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + } + let future: PgConfigIoMethod = serde_json::from_str(r#""posix_aio""#).unwrap(); + assert_eq!(future, PgConfigIoMethod::Unknown("posix_aio".to_string())); + assert_eq!(future.to_string(), "posix_aio"); +} + +#[test] +fn postgres_slow_query_sort_values_roundtrip_as_known_variants() { + for value in SlowQueryPatternsGetListSortby::VALUES { + let parsed: SlowQueryPatternsGetListSortby = + serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!(parsed, SlowQueryPatternsGetListSortby::Unknown(_)), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + } + for value in SlowQueryPatternsGetListSortorder::VALUES { + let parsed: SlowQueryPatternsGetListSortorder = + serde_json::from_str(&format!(r#""{value}""#)).unwrap(); + assert!( + !matches!(parsed, SlowQueryPatternsGetListSortorder::Unknown(_)), + "{value} fell through to the catch-all" + ); + assert_eq!(parsed.to_string(), *value); + } +} + +#[test] +fn postgres_gcp_sizes_roundtrip_as_known_variants() { + let sizes = [ + "c4a-highmem-lssd-4", + "c4a-highmem-lssd-8", + "c4a-highmem-lssd-16", + "c4a-highmem-lssd-32", + "c4a-highmem-lssd-48", + "c4a-highmem-lssd-64", + "c4a-highmem-lssd-72", + "c4a-standard-lssd-4", + "c4a-standard-lssd-8", + "c4a-standard-lssd-16", + "c4a-standard-lssd-32", + "c4a-standard-lssd-48", + "c4a-standard-lssd-64", + "c4a-standard-lssd-72", + "c4-highmem-lssd-4", + "c4-highmem-lssd-8", + "c4-highmem-lssd-16", + "c4-highmem-lssd-24", + "c4-highmem-lssd-32", + "c4-highmem-lssd-48", + "c4-highmem-lssd-96", + "c4-highmem-lssd-144", + "c4-highmem-lssd-192", + "c4-highmem-lssd-288", + "c4-standard-lssd-4", + "c4-standard-lssd-8", + "c4-standard-lssd-16", + "c4-standard-lssd-24", + "c4-standard-lssd-32", + "c4-standard-lssd-48", + "c4-standard-lssd-96", + "c4-standard-lssd-144", + "c4-standard-lssd-192", + "c4-standard-lssd-288", + "c4d-highmem-lssd-8", + "c4d-highmem-lssd-16", + "c4d-highmem-lssd-32", + "c4d-highmem-lssd-48", + "c4d-highmem-lssd-64", + "c4d-highmem-lssd-96", + "c4d-highmem-lssd-192", + "c4d-highmem-lssd-384", + "c4d-standard-lssd-8", + "c4d-standard-lssd-16", + "c4d-standard-lssd-32", + "c4d-standard-lssd-48", + "c4d-standard-lssd-64", + "c4d-standard-lssd-96", + "c4d-standard-lssd-192", + "c4d-standard-lssd-384", + "z3-highmem-highlssd-8", + "z3-highmem-highlssd-16", + "z3-highmem-highlssd-22", + "z3-highmem-highlssd-32", + "z3-highmem-highlssd-44", + "z3-highmem-highlssd-88", + "z3-highmem-standardlssd-14", + "z3-highmem-standardlssd-22", + "z3-highmem-standardlssd-44", + "z3-highmem-standardlssd-88", + "z3-highmem-standardlssd-176", + ]; + + for wire_value in sizes { + let size: PgSize = serde_json::from_value(wire_value.into()).unwrap(); + assert!( + !matches!(&size, PgSize::Unknown(_)), + "{wire_value} deserialized through the catch-all" + ); + assert_eq!(size.to_string(), wire_value); + assert_eq!(serde_json::to_value(&size).unwrap(), wire_value); + } +} + +#[test] +fn serialize_postgres_service_post_request() { + let req = PostgresServicePostRequest { + name: "pg-new".to_string(), + provider: PgProvider::Aws, + region: "us-east-1".to_string(), + size: PgSize::C6gd_large, + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["name"], "pg-new"); + assert_eq!(json["provider"], "aws"); + assert_eq!(json["region"], "us-east-1"); + assert_eq!(json["size"], "c6gd.large"); + assert!(json.get("storageSize").is_none()); + // Optional fields omitted + assert!(json.get("haType").is_none()); + assert!(json.get("pgConfig").is_none()); + assert!(json.get("pgBouncerConfig").is_none()); +} + +#[test] +fn serialize_postgres_service_set_state() { + let req = PostgresServiceSetState { + command: PostgresServiceSetStateCommand::Restart, + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["command"], "restart"); +} + +#[test] +fn serialize_postgres_service_set_password() { + let req = PostgresServiceSetPassword { + password: "s3cur3".to_string(), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["password"], "s3cur3"); +} + +#[test] +fn serialize_postgres_read_replica_request() { + let req = PostgresServiceReadReplicaRequest { + name: "pg-replica".to_string(), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["name"], "pg-replica"); + assert!(json.get("pgConfig").is_none()); + assert!(json.get("pgBouncerConfig").is_none()); +} + +#[test] +fn slow_query_sort_enums_preserve_unknown_values() { + let sort_by: SlowQueryPatternsGetListSortby = + serde_json::from_str(r#""future_metric""#).unwrap(); + let sort_order: SlowQueryPatternsGetListSortorder = + serde_json::from_str(r#""future_order""#).unwrap(); + + assert_eq!(sort_by.to_string(), "future_metric"); + assert_eq!(sort_order.to_string(), "future_order"); +} + +#[test] +fn serialize_postgres_instance_config() { + let config = PostgresInstanceConfig { + pg_config: PgConfig { + max_connections: Some(serde_json::json!(200)), + autovacuum_max_workers: Some(serde_json::json!(5)), + ..Default::default() + }, + pg_bouncer_config: PgBouncerConfig::default(), + }; + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["pgConfig"]["max_connections"], 200); + assert_eq!(json["pgConfig"]["autovacuum_max_workers"], 5); + assert!(json.get("pgBouncerConfig").is_some()); +} + +#[test] +fn serialize_postgres_instance_config_always_includes_both_nested() { + // The live API rejects PATCH/POST bodies that omit either `pgConfig` or + // `pgBouncerConfig` with `BAD_REQUEST: ... 'undefined'`, so the envelope + // always serializes both — defaulting to `{}` — while inner pgConfig + // fields stay opt-in. See #163 for the matrix evidence. + let config = PostgresInstanceConfig { + pg_config: PgConfig { + max_connections: Some(serde_json::json!(200)), + ..Default::default() + }, + pg_bouncer_config: PgBouncerConfig::default(), + }; + let json = serde_json::to_value(&config).unwrap(); + assert!( + json.get("pgBouncerConfig").is_some(), + "pgBouncerConfig must always be present" + ); + assert_eq!(json["pgBouncerConfig"], serde_json::json!({})); + assert_eq!(json["pgConfig"]["max_connections"], 200); + let pg = json["pgConfig"].as_object().unwrap(); + assert_eq!( + pg.len(), + 1, + "PgConfig should only serialize the one set field, got {pg:?}" + ); +} + +#[test] +fn serialize_postgres_instance_config_default_envelope() { + // Default envelope serializes to the minimal accepted body shape. + let json = serde_json::to_value(PostgresInstanceConfig::default()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "pgConfig": {}, "pgBouncerConfig": {} }) + ); +} + +#[test] +fn postgres_service_ignores_extra_fields() { + let json = r#"{"name":"pg","state":"running","maintenanceWindow":"sun-02:00"}"#; + let pg: PostgresService = serde_json::from_str(json).unwrap(); + assert_eq!(pg.name.as_deref(), Some("pg")); +} + +#[test] +fn postgres_service_minimal_response() { + let pg: PostgresService = + serde_json::from_str(r#"{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}"#).unwrap(); + // Every response field is `Option`: an omitted key is `None`, not a + // fabricated zero value. + assert_eq!(pg.name, None); + assert_eq!(pg.state, None); +} + +#[test] +fn postgres_service_response_tolerates_dropped_and_null_fields() { + // A response field the API stops sending, and one it sends as an explicit + // `null`, must both land as `None` rather than failing the response. `null` + // is the case `#[serde(default)]` never covered: it only fills a missing + // key. + let dropped: PostgresService = serde_json::from_str("{}").unwrap(); + let nulled: PostgresService = serde_json::from_str( + r#"{"id":null,"name":null,"state":null,"tags":null,"storageSize":null,"createdAt":null}"#, + ) + .unwrap(); + assert_eq!(dropped, PostgresService::default()); + assert_eq!(nulled, PostgresService::default()); + assert_eq!(nulled.tags, None); + assert_eq!(nulled.storage_size, None); +} + +#[test] +fn postgres_service_response_omits_absent_fields_when_serialized() { + // Absent means absent: a response field that was not returned is omitted + // from `--json` output rather than emitted as `null`. + let pg = PostgresService { + name: Some("pg-1".to_string()), + ..Default::default() + }; + let json = serde_json::to_value(&pg).unwrap(); + assert_eq!(json, serde_json::json!({ "name": "pg-1" })); +} + +#[test] +fn postgres_instance_config_response_converts_back_into_a_request_body() { + let response = PostgresInstanceConfigResponse { + pg_config: Some(PgConfigResponse { + max_connections: Some(serde_json::json!(200)), + ..Default::default() + }), + pg_bouncer_config: Some(PgBouncerConfigResponse::from([ + ("default_pool_size".to_string(), "16".to_string()), + ("future_parameter".to_string(), "on".to_string()), + ])), + }; + let request = PostgresInstanceConfig::try_from(response).unwrap(); + assert_eq!( + request.pg_config.max_connections, + Some(serde_json::json!(200)) + ); + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::json!({ "pgConfig": { "max_connections": 200 }, "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"} }) + ); +} + +#[test] +fn postgres_instance_config_response_conversion_reports_missing_required_fields() { + // Both nested objects are required in a write body, so the write-back + // conversion must fail loudly instead of inventing empty objects. + let missing_bouncer = PostgresInstanceConfig::try_from(PostgresInstanceConfigResponse { + pg_config: Some(PgConfigResponse::default()), + pg_bouncer_config: None, + }) + .unwrap_err(); + assert_eq!(missing_bouncer.fields(), ["pgBouncerConfig"]); + + let missing_both = + PostgresInstanceConfig::try_from(PostgresInstanceConfigResponse::default()).unwrap_err(); + assert_eq!(missing_both.fields(), ["pgBouncerConfig", "pgConfig"]); + assert_eq!( + missing_both.to_string(), + "the API response is missing required field(s): pgBouncerConfig, pgConfig" + ); +} + +#[test] +fn deserialize_postgres_instance_config() { + let json = r#"{ + "pgConfig": { + "max_connections": 200, + "shared_buffers": "256MB" + }, + "pgBouncerConfig": {} + }"#; + let config: PostgresInstanceConfigResponse = serde_json::from_str(json).unwrap(); + assert_eq!( + config.pg_config.unwrap().max_connections, + Some(serde_json::json!(200)) + ); +} + +#[test] +fn deserialize_postgres_instance_config_string_wrapped_numbers() { + // The live GET endpoint returns numeric pgConfig values wrapped in JSON + // strings (e.g. "max_connections": "100"). The spec types these fields + // as string-or-number, so they are modelled as serde_json::Value and + // both representations must deserialize. + let json = r#"{ + "pgConfig": { + "max_connections": "100", + "random_page_cost": "1.1", + "max_worker_processes": 8, + "autovacuum_naptime": "5s", + "autovacuum_vacuum_scale_factor": "0.2", + "autovacuum_max_workers": 3 + }, + "pgBouncerConfig": {} + }"#; + let config: PostgresInstanceConfigResponse = serde_json::from_str(json).unwrap(); + let pg_config = config.pg_config.expect("pgConfig present in the payload"); + assert_eq!(pg_config.max_connections, Some(serde_json::json!("100"))); + assert_eq!(pg_config.random_page_cost, Some(serde_json::json!("1.1"))); + assert_eq!(pg_config.max_worker_processes, Some(serde_json::json!(8))); + assert_eq!(pg_config.autovacuum_naptime, Some(serde_json::json!("5s"))); + assert_eq!( + pg_config.autovacuum_vacuum_scale_factor, + Some(serde_json::json!("0.2")) + ); + assert_eq!(pg_config.autovacuum_max_workers, Some(serde_json::json!(3))); +} + +#[test] +fn pg_state_property_stopped() { + let parsed: PgStateProperty = serde_json::from_str("\"stopped\"").unwrap(); + assert_eq!(parsed, PgStateProperty::Stopped); + assert_eq!(parsed.to_string(), "stopped"); +} + +#[test] +fn pgbouncer_map_round_trips_arbitrary_string_parameters() { + let document = serde_json::json!({ + "default_pool_size": "16", "future_parameter": "on", "empty": "" + }); + let request: PgBouncerConfig = serde_json::from_value(document.clone()).unwrap(); + let response: PgBouncerConfigResponse = serde_json::from_value(document.clone()).unwrap(); + assert_eq!(request, response); + assert_eq!(serde_json::to_value(request).unwrap(), document); + assert_eq!(serde_json::to_value(response).unwrap(), document); +} + +#[test] +fn pgbouncer_requests_reject_non_string_values() { + for value in [ + serde_json::json!(16), + serde_json::json!(true), + serde_json::json!(null), + serde_json::json!([]), + serde_json::json!({}), + ] { + assert!( + serde_json::from_value::( + serde_json::json!({"default_pool_size": value}) + ) + .is_err() + ); + } +} + +#[test] +fn pgbouncer_parent_responses_preserve_missing_null_and_empty_sections() { + for document in [ + serde_json::json!({}), + serde_json::json!({"pgBouncerConfig": null}), + ] { + let config: PostgresInstanceConfigResponse = + serde_json::from_value(document.clone()).unwrap(); + assert!(config.pg_bouncer_config.is_none()); + assert_eq!(serde_json::to_value(config).unwrap(), serde_json::json!({})); + let update: PostgresInstanceUpdateConfigResponse = + serde_json::from_value(document).unwrap(); + assert!(update.pg_bouncer_config.is_none()); + assert_eq!(serde_json::to_value(update).unwrap(), serde_json::json!({})); + } + let config: PostgresInstanceConfigResponse = serde_json::from_value( + serde_json::json!({"pgBouncerConfig": {}, "futureField": "ignored"}), + ) + .unwrap(); + assert_eq!(config.pg_bouncer_config, Some(PgBouncerConfig::new())); + assert_eq!( + serde_json::to_value(config).unwrap(), + serde_json::json!({"pgBouncerConfig": {}}) + ); +} + +#[test] +fn postgres_slow_query_durations_preserve_fractions_and_integral_counts() { + let aggregate = serde_json::json!({ + "avgDurationUs": 1190.8419405320817, + "maxDurationUs": 2000.75, + "p50DurationUs": 1100.25, + "p95DurationUs": 1800.5, + "p99DurationUs": 1950.125, + "totalDurationUs": 3572.525821596245, + "callCount": 3, "errorCount": 0, "totalRows": 9007199254740993_i64, + "totalSharedBlksHit": 10, "totalSharedBlksRead": 2, + "totalCpuTimeUs": 2000, "totalWalBytes": 128 + }); + let list: Vec = + serde_json::from_value(serde_json::json!([aggregate])).unwrap(); + assert_eq!(list[0].avg_duration_us, Some(1190.8419405320817)); + assert_eq!(list[0].total_rows, Some(9007199254740993)); + assert_eq!( + serde_json::to_value(&list).unwrap(), + serde_json::json!([aggregate]) + ); + + let detail = serde_json::json!({ + "aggregate": aggregate, + "recentExecutions": [{"durationUs": 1234.56789, "rows": 9007199254740993_i64, + "cpuSysTimeUs": 12, "jitFunctions": 2, "sharedBlksHit": 3, "walBytes": 128}] + }); + let parsed: PostgresSlowQueryPatternDetail = serde_json::from_value(detail.clone()).unwrap(); + assert_eq!( + parsed.recent_executions.as_ref().unwrap()[0].duration_us, + Some(1234.56789) + ); + assert_eq!(serde_json::to_value(parsed).unwrap(), detail); + + // Existing integer-valued measurements remain accepted as durations. + let integer: PostgresQueryExecution = + serde_json::from_value(serde_json::json!({"durationUs": 1234})).unwrap(); + assert_eq!(integer.duration_us, Some(1234.0)); + let integer: PostgresSlowQueryPattern = serde_json::from_value(serde_json::json!({ + "avgDurationUs": 1, "maxDurationUs": 2, "p50DurationUs": 1, + "p95DurationUs": 2, "p99DurationUs": 2, "totalDurationUs": 3 + })) + .unwrap(); + assert_eq!(integer.avg_duration_us, Some(1.0)); + for field in [ + "callCount", + "errorCount", + "totalRows", + "totalSharedBlksHit", + "totalSharedBlksRead", + "totalWalBytes", + ] { + assert!( + serde_json::from_value::(serde_json::json!({field: 1.5})) + .is_err(), + "{field} must remain integral" + ); + } + for field in ["rows", "jitFunctions", "sharedBlksHit", "walBytes"] { + assert!( + serde_json::from_value::(serde_json::json!({field: 1.5})) + .is_err(), + "{field} must remain integral" + ); + } +} + +#[test] +fn postgres_slow_query_durations_allow_missing_and_null() { + for aggregate in [ + serde_json::json!({}), + serde_json::json!({ + "avgDurationUs": null, "maxDurationUs": null, "p50DurationUs": null, + "p95DurationUs": null, "p99DurationUs": null, "totalDurationUs": null + }), + ] { + let parsed: PostgresSlowQueryPattern = serde_json::from_value(aggregate.clone()).unwrap(); + assert_eq!(parsed, PostgresSlowQueryPattern::default()); + assert_eq!(serde_json::to_value(parsed).unwrap(), serde_json::json!({})); + let detail: PostgresSlowQueryPatternDetail = serde_json::from_value(serde_json::json!({ + "aggregate": aggregate, "recentExecutions": [{}, {"durationUs": null}] + })) + .unwrap(); + assert_eq!(detail.aggregate, Some(PostgresSlowQueryPattern::default())); + assert_eq!( + detail.recent_executions, + Some(vec![PostgresQueryExecution::default(); 2]) + ); + assert_eq!( + serde_json::to_value(detail).unwrap(), + serde_json::json!({"aggregate": {}, "recentExecutions": [{}, {}]}) + ); + } + for value in [ + serde_json::json!({}), + serde_json::json!({"aggregate": null, "recentExecutions": null}), + ] { + let parsed: PostgresSlowQueryPatternDetail = serde_json::from_value(value).unwrap(); + assert_eq!(parsed, PostgresSlowQueryPatternDetail::default()); + assert_eq!(serde_json::to_value(parsed).unwrap(), serde_json::json!({})); + } +} + +#[test] +fn postgres_backup_tolerates_missing_and_null_fields() { + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "key": null, "lastModified": null, "status": null, "futureField": 1 + }), + ] { + let backup: PostgresBackup = serde_json::from_value(wire).unwrap(); + assert_eq!(backup, PostgresBackup::default()); + assert_eq!(serde_json::to_value(backup).unwrap(), serde_json::json!({})); + } +} + +#[test] +fn postgres_backup_round_trips_every_field() { + let wire = serde_json::json!({ + "key": "basebackups_005/000000010000000000000002_backup_stop_sentinel.json", + "lastModified": "2026-03-31T18:17:37Z", + "status": "ready" + }); + let backup: PostgresBackup = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + backup.key.as_deref(), + Some("basebackups_005/000000010000000000000002_backup_stop_sentinel.json") + ); + assert!(backup.last_modified.is_some()); + assert_eq!(backup.status.as_deref(), Some("ready")); + assert_eq!(serde_json::to_value(backup).unwrap(), wire); +} + +#[test] +fn postgres_service_access_list_and_restore_time_tolerate_missing_and_null() { + for wire in [ + serde_json::json!({}), + serde_json::json!({"ipAccessList": null, "earliestRestoreTime": null}), + ] { + let pg: PostgresService = serde_json::from_value(wire).unwrap(); + assert_eq!(pg.ip_access_list, None); + assert_eq!(pg.earliest_restore_time, None); + assert_eq!(serde_json::to_value(pg).unwrap(), serde_json::json!({})); + } +} + +#[test] +fn postgres_service_round_trips_access_list_and_restore_time() { + let wire = serde_json::json!({ + "earliestRestoreTime": "2026-03-31T18:17:37Z", + "ipAccessList": [ + { + "id": "rule-1", + "source": "203.0.113.0/24", + "portRange": "5432", + "description": "office" + }, + {"source": "198.51.100.7/32", "portRange": "5432..6432"} + ] + }); + let pg: PostgresService = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + pg.earliest_restore_time, + Some("2026-03-31T18:17:37Z".parse().unwrap()) + ); + let rules = pg.ip_access_list.as_ref().unwrap(); + assert_eq!(rules.len(), 2); + assert_eq!(rules[0].id.as_deref(), Some("rule-1")); + assert_eq!(rules[0].port_range.as_deref(), Some("5432")); + assert_eq!(rules[1].description, None); + assert_eq!(serde_json::to_value(pg).unwrap(), wire); +} + +#[test] +fn postgres_ip_access_list_entry_response_tolerates_missing_and_null() { + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "id": null, "source": null, "portRange": null, "description": null, + "futureField": 1 + }), + ] { + let entry: PostgresIpAccessListEntryResponse = serde_json::from_value(wire).unwrap(); + assert_eq!(entry, PostgresIpAccessListEntryResponse::default()); + assert_eq!(serde_json::to_value(entry).unwrap(), serde_json::json!({})); + } +} + +#[test] +fn postgres_ip_access_list_request_types_are_strict() { + // `source` and `portRange` are required on a rule, `id` on a rule + // reference, and both lists on a patch: a body missing one is rejected + // rather than silently defaulted. + assert!(serde_json::from_str::("{}").is_err()); + assert!( + serde_json::from_str::(r#"{"source":"203.0.113.0/24"}"#) + .is_err() + ); + assert!(serde_json::from_str::(r#"{"portRange":"5432"}"#).is_err()); + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::(r#"{"add":[]}"#).is_err()); + assert!(serde_json::from_str::(r#"{"remove":[]}"#).is_err()); + + // A new rule omits `id` and `description`. + let entry = PostgresIpAccessListEntry { + source: "203.0.113.0/24".to_string(), + port_range: "5432".to_string(), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&entry).unwrap(), + serde_json::json!({"source": "203.0.113.0/24", "portRange": "5432"}) + ); +} + +#[test] +fn postgres_ip_access_list_entry_response_converts_back_into_a_request_entry() { + let entry = PostgresIpAccessListEntry::try_from(PostgresIpAccessListEntryResponse { + id: Some("rule-1".to_string()), + source: Some("203.0.113.0/24".to_string()), + port_range: Some("6432".to_string()), + description: Some("office".to_string()), + }) + .unwrap(); + // The returned `id` survives, so writing the rule back edits it. + assert_eq!( + serde_json::to_value(&entry).unwrap(), + serde_json::json!({ + "id": "rule-1", "source": "203.0.113.0/24", "portRange": "6432", + "description": "office" + }) + ); + + let missing_port = PostgresIpAccessListEntry::try_from(PostgresIpAccessListEntryResponse { + source: Some("203.0.113.0/24".to_string()), + ..Default::default() + }) + .unwrap_err(); + assert_eq!(missing_port.fields(), ["portRange"]); + + let missing_both = + PostgresIpAccessListEntry::try_from(PostgresIpAccessListEntryResponse::default()) + .unwrap_err(); + assert_eq!(missing_both.fields(), ["portRange", "source"]); +} + +#[test] +fn postgres_service_post_request_distinguishes_omitted_and_empty_access_list() { + let base = PostgresServicePostRequest { + name: "pg-new".to_string(), + provider: PgProvider::Aws, + region: "us-east-1".to_string(), + size: PgSize::C6gd_large, + ..Default::default() + }; + // Omitted allows all IPv4 and IPv6 addresses. + let omitted = serde_json::to_value(&base).unwrap(); + assert!(omitted.get("ipAccessList").is_none(), "{omitted}"); + + // An empty list blocks public access, so it must reach the wire as `[]`. + let blocked = PostgresServicePostRequest { + ip_access_list: Some(Vec::new()), + ..base.clone() + }; + let blocked = serde_json::to_value(&blocked).unwrap(); + assert_eq!(blocked["ipAccessList"], serde_json::json!([])); + + let allowed = PostgresServicePostRequest { + ip_access_list: Some(vec![PostgresIpAccessListEntry { + source: "203.0.113.0/24".to_string(), + port_range: "5432".to_string(), + ..Default::default() + }]), + ..base + }; + assert_eq!( + serde_json::to_value(&allowed).unwrap()["ipAccessList"], + serde_json::json!([{"source": "203.0.113.0/24", "portRange": "5432"}]) + ); +} + +#[test] +fn postgres_service_patch_request_serializes_the_access_list_patch() { + let untouched = PostgresServicePatchRequest::default(); + assert_eq!( + serde_json::to_value(&untouched).unwrap(), + serde_json::json!({}) + ); + + let patch = PostgresServicePatchRequest { + ip_access_list: Some(PostgresIpAccessListPatch { + add: vec![PostgresIpAccessListEntry { + id: Some("rule-1".to_string()), + source: "203.0.113.0/24".to_string(), + port_range: "6432".to_string(), + description: None, + }], + remove: vec![PostgresIpAccessListRuleReference { + id: "rule-2".to_string(), + }], + }), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&patch).unwrap(), + serde_json::json!({ + "ipAccessList": { + "add": [{"id": "rule-1", "source": "203.0.113.0/24", "portRange": "6432"}], + "remove": [{"id": "rule-2"}] + } + }) + ); +} + +#[test] +fn pg_config_carries_new_gucs_through_write_back() { + let response: PgConfigResponse = serde_json::from_value(serde_json::json!({ + "autovacuum_analyze_threshold": 50, + "autovacuum_freeze_max_age": 200000000, + "autovacuum_vacuum_insert_threshold": -1, + "autovacuum_vacuum_threshold": 50, + "checkpoint_timeout": "5min", + "shared_memory_percent": 25, + "wal_buffers": "16MB" + })) + .unwrap(); + let missing: PgConfigResponse = serde_json::from_str( + r#"{"autovacuum_analyze_threshold":null,"checkpoint_timeout":null,"wal_buffers":null}"#, + ) + .unwrap(); + assert_eq!(missing, PgConfigResponse::default()); + + let request = PgConfig::from(response); + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::json!({ + "autovacuum_analyze_threshold": 50, + "autovacuum_freeze_max_age": 200000000, + "autovacuum_vacuum_insert_threshold": -1, + "autovacuum_vacuum_threshold": 50, + "checkpoint_timeout": "5min", + "shared_memory_percent": 25, + "wal_buffers": "16MB" + }) + ); +} + +#[test] +fn pg_config_carries_io_logging_and_replication_gucs_through_write_back() { + let wire = serde_json::json!({ + "bgwriter_delay": "200ms", + "bgwriter_flush_after": "512kB", + "bgwriter_lru_maxpages": 100, + "bgwriter_lru_multiplier": 2, + "default_toast_compression": "lz4", + "enable_parallel_hash": "off", + "hash_mem_multiplier": 2, + "io_method": "io_uring", + "io_workers": 3, + "log_lock_waits": "on", + "log_min_duration_statement": 1000, + "log_parameter_max_length": "-1", + "log_temp_files": -1, + "logical_decoding_work_mem": "64MB", + "maintenance_io_concurrency": 10, + "max_logical_replication_workers": 4, + "max_replication_slots": 10, + "max_sync_workers_per_subscription": 2, + "max_wal_senders": 10, + "temp_buffers": "8MB", + "track_activity_query_size": 4096, + "vacuum_cost_delay": "0" + }); + let response: PgConfigResponse = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + response.default_toast_compression, + Some(PgConfigDefaultToastCompression::Lz4) + ); + assert_eq!( + response.enable_parallel_hash, + Some(PgConfigEnableParallelHash::Off) + ); + assert_eq!(response.io_method, Some(PgConfigIoMethod::Io_uring)); + assert_eq!(response.log_lock_waits, Some(PgConfigLogLockWaits::On)); + assert_eq!(response.log_temp_files, Some(serde_json::json!(-1))); + + let missing: PgConfigResponse = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, PgConfigResponse::default()); + let null_fields = wire + .as_object() + .unwrap() + .keys() + .map(|key| (key.clone(), serde_json::Value::Null)) + .collect::>(); + let nulls: PgConfigResponse = + serde_json::from_value(serde_json::Value::Object(null_fields)).unwrap(); + assert_eq!(nulls, PgConfigResponse::default()); + + // Write-back is lossless: every new GUC survives the response -> request + // conversion with its wire name and value unchanged. + let request = PgConfig::from(response); + assert_eq!(serde_json::to_value(&request).unwrap(), wire); +} + +#[test] +fn pg_config_serializes_only_the_new_gucs_that_are_set() { + let config = PgConfig { + io_method: Some(PgConfigIoMethod::Worker), + io_workers: Some(serde_json::json!(8)), + log_min_duration_statement: Some(serde_json::json!("250ms")), + max_wal_senders: Some(serde_json::json!(20)), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&config).unwrap(), + serde_json::json!({ + "io_method": "worker", + "io_workers": 8, + "log_min_duration_statement": "250ms", + "max_wal_senders": 20 + }) + ); +} diff --git a/crates/clickhouse-cloud-api/tests/models/query_api_endpoints.rs b/crates/clickhouse-cloud-api/tests/models/query_api_endpoints.rs new file mode 100644 index 00000000..287ccf5d --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/query_api_endpoints.rs @@ -0,0 +1,127 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn query_api_endpoint_request_is_strict_and_omits_optional_fields() { + let required = serde_json::json!({ + "name": "orders", + "sql": "SELECT * FROM orders WHERE id = {id:String}", + "database": "default", + "apiKeyIds": ["11111111-2222-3333-8444-555555555555"], + "roles": ["query_api"] + }); + let request: PublicQueryApiEndpointRequest = serde_json::from_value(required.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), required); + + for field in ["name", "sql", "database", "apiKeyIds", "roles"] { + let mut missing = required.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(missing).is_err(), + "missing required field {field} must fail" + ); + } +} + +#[test] +fn query_api_endpoint_responses_tolerate_missing_and_null_fields() { + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "id": null, + "name": null, + "sql": null, + "database": null, + "parameters": null, + "apiKeyIds": null, + "roles": null, + "allowedOrigins": null, + "url": null, + "ownerType": null + }), + ] { + let endpoint: PublicQueryApiEndpoint = serde_json::from_value(wire).unwrap(); + assert_eq!(endpoint, PublicQueryApiEndpoint::default()); + assert_eq!( + serde_json::to_value(endpoint).unwrap(), + serde_json::json!({}) + ); + } + + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "id": null, + "name": null, + "database": null, + "apiKeyIds": null, + "roles": null, + "allowedOrigins": null, + "url": null, + "ownerType": null + }), + ] { + let item: PublicQueryApiEndpointListItem = serde_json::from_value(wire).unwrap(); + assert_eq!(item, PublicQueryApiEndpointListItem::default()); + assert_eq!(serde_json::to_value(item).unwrap(), serde_json::json!({})); + } + + for wire in [ + serde_json::json!({}), + serde_json::json!({"items": null, "pagination": null}), + ] { + let list: QueryApiEndpointListResponse = serde_json::from_value(wire).unwrap(); + assert_eq!(list, QueryApiEndpointListResponse::default()); + assert_eq!(serde_json::to_value(list).unwrap(), serde_json::json!({})); + } +} + +#[test] +fn query_api_endpoint_list_response_preserves_array_entries_and_envelope_pagination() { + for items in [serde_json::json!([]), serde_json::json!([{}])] { + let response: ApiResponse> = + serde_json::from_value(serde_json::json!({ + "result": items, "limit": 1, "totalCount": 2, + "nextCursor": "opaque +/=&? page", "futureEnvelopeField": true + })) + .unwrap(); + assert_eq!( + serde_json::to_value(response.result.unwrap()).unwrap(), + items + ); + assert_eq!(response.limit, Some(1)); + assert_eq!(response.total_count, Some(2)); + assert_eq!(response.next_cursor.as_deref(), Some("opaque +/=&? page")); + } + for wire in [serde_json::json!({}), serde_json::json!({"result": null})] { + let response: ApiResponse> = + serde_json::from_value(wire).unwrap(); + assert!(response.result.is_none()); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({}) + ); + } +} + +#[test] +fn query_api_endpoint_owner_types_preserve_unknown_values() { + let owner: PublicQueryApiEndpointOwnertype = serde_json::from_str("\"futureOwner\"").unwrap(); + assert_eq!( + owner, + PublicQueryApiEndpointOwnertype::Unknown("futureOwner".into()) + ); + assert_eq!(owner.to_string(), "futureOwner"); + assert_eq!(serde_json::to_string(&owner).unwrap(), "\"futureOwner\""); + + let list_owner: PublicQueryApiEndpointListItemOwnertype = + serde_json::from_str("\"futureOwner\"").unwrap(); + assert_eq!( + list_owner, + PublicQueryApiEndpointListItemOwnertype::Unknown("futureOwner".into()) + ); + assert_eq!(list_owner.to_string(), "futureOwner"); + assert_eq!( + serde_json::to_string(&list_owner).unwrap(), + "\"futureOwner\"" + ); +} diff --git a/crates/clickhouse-cloud-api/tests/models/saved_queries.rs b/crates/clickhouse-cloud-api/tests/models/saved_queries.rs new file mode 100644 index 00000000..6bf53af8 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/saved_queries.rs @@ -0,0 +1,83 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn saved_query_request_is_strict_and_omits_optional_fields() { + let required = serde_json::json!({ + "name": "orders", + "sql": "SELECT * FROM orders WHERE id = {id:String}", + "database": "default" + }); + let request: PublicSavedQueryRequest = serde_json::from_value(required.clone()).unwrap(); + assert_eq!(request.parameters, None); + assert_eq!(serde_json::to_value(&request).unwrap(), required); + + for field in ["name", "sql", "database"] { + let mut missing = required.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(missing).is_err(), + "missing required field {field} must fail" + ); + } + + let maximal = PublicSavedQueryRequest { + parameters: Some(std::collections::BTreeMap::from([( + "id".to_string(), + "42".to_string(), + )])), + ..request + }; + let mut expected = required; + expected["parameters"] = serde_json::json!({"id": "42"}); + assert_eq!(serde_json::to_value(maximal).unwrap(), expected); +} + +#[test] +fn saved_query_responses_tolerate_missing_and_null_fields() { + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "id": null, + "name": null, + "sql": null, + "database": null, + "parameters": null + }), + ] { + let query: PublicSavedQuery = serde_json::from_value(wire).unwrap(); + assert_eq!(query, PublicSavedQuery::default()); + assert_eq!(serde_json::to_value(query).unwrap(), serde_json::json!({})); + } + + for wire in [ + serde_json::json!({}), + serde_json::json!({"id": null, "name": null, "database": null}), + ] { + let item: PublicSavedQueryListItem = serde_json::from_value(wire).unwrap(); + assert_eq!(item, PublicSavedQueryListItem::default()); + assert_eq!(serde_json::to_value(item).unwrap(), serde_json::json!({})); + } +} + +#[test] +fn saved_query_response_round_trips_every_field_and_ignores_unknown_keys() { + let wire = serde_json::json!({ + "id": "11111111-2222-3333-8444-555555555555", + "name": "orders", + "sql": "SELECT {id:String}", + "database": "default", + "parameters": {"id": "42"} + }); + let mut with_unknown = wire.clone(); + with_unknown["futureField"] = serde_json::json!(true); + let query: PublicSavedQuery = serde_json::from_value(with_unknown).unwrap(); + assert_eq!(serde_json::to_value(query).unwrap(), wire); + + let item_wire = serde_json::json!({ + "id": "11111111-2222-3333-8444-555555555555", + "name": "orders", + "database": "default" + }); + let item: PublicSavedQueryListItem = serde_json::from_value(item_wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(item).unwrap(), item_wire); +} diff --git a/crates/clickhouse-cloud-api/tests/models/scim.rs b/crates/clickhouse-cloud-api/tests/models/scim.rs new file mode 100644 index 00000000..44601e0a --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/scim.rs @@ -0,0 +1,104 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn scim_user_enterprise_extension_uses_the_exact_wire_name() { + let user = ScimUser { + enterprise_user: Some(ScimEnterpriseUser::default()), + ..Default::default() + }; + let json = serde_json::to_value(&user).unwrap(); + let extension = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"; + assert!(json.get(extension).is_some()); + assert_eq!( + serde_json::from_value::(json) + .unwrap() + .enterprise_user, + user.enterprise_user + ); +} + +#[test] +fn scim_request_models_stay_strict() { + // The spec defines the SCIM schemas but no SCIM path, so the family is in + // neither direction's tree and stays strict (see the comment above + // `ScimEnterpriseManager` in models.rs and + // `scim_models_are_outside_the_response_tree` in spec_coverage_test.rs). + // Required fields are therefore `T` and a dropped one is a hard error. + let err = serde_json::from_str::(r#"{"schemas":[]}"#).unwrap_err(); + assert!( + err.to_string().contains("displayName"), + "unexpected error: {err}" + ); + + // Absent optional fields are still omitted rather than sent as `null`. + let group = ScimGroupPostRequest { + display_name: "Engineering".to_string(), + external_id: None, + id: None, + members: None, + meta: None, + schemas: vec!["urn:ietf:params:scim:schemas:core:2.0:Group".to_string()], + }; + let v = serde_json::to_value(&group).unwrap(); + assert_eq!(v["displayName"], "Engineering"); + assert_eq!( + v["schemas"][0], + "urn:ietf:params:scim:schemas:core:2.0:Group" + ); + assert!(v.get("externalId").is_none()); + assert!(v.get("members").is_none()); + assert!(v.get("id").is_none()); + assert!(v.get("meta").is_none()); +} + +#[test] +fn scim_request_echoed_id_and_meta_round_trip() { + let created = "2026-01-02T03:04:05Z"; + let modified = "2026-01-03T03:04:05Z"; + let patch: ScimPatchOp = serde_json::from_value(serde_json::json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [], + "id": "user-1", + "meta": {"resourceType": "User", "created": created, "lastModified": modified} + })) + .unwrap(); + assert_eq!(patch.id.as_deref(), Some("user-1")); + let Some(ScimPatchOpMeta::ScimUserMeta(meta)) = &patch.meta else { + panic!("expected user meta, got {:?}", patch.meta); + }; + assert_eq!(meta.resource_type, "User"); + assert_eq!( + serde_json::to_value(&patch).unwrap()["meta"]["resourceType"], + "User" + ); + + let group_meta = serde_json::json!({ + "resourceType": "Group", "created": created, "lastModified": modified + }); + let parsed: ScimPatchOpMeta = serde_json::from_value(group_meta.clone()).unwrap(); + assert!(matches!(parsed, ScimPatchOpMeta::ScimGroupMeta(_))); + let other = serde_json::json!({"resourceType": "Device"}); + let parsed: ScimPatchOpMeta = serde_json::from_value(other.clone()).unwrap(); + assert_eq!(parsed, ScimPatchOpMeta::Unknown(other)); + + let group: ScimGroupPostRequest = serde_json::from_value(serde_json::json!({ + "schemas": [], "displayName": "Engineering", "id": "group-1", "meta": group_meta + })) + .unwrap(); + assert_eq!(group.id.as_deref(), Some("group-1")); + assert_eq!( + group.meta.map(|meta| meta.resource_type).as_deref(), + Some("Group") + ); + + let user = ScimUserPostRequest { + user_name: "ada".to_string(), + ..Default::default() + }; + let v = serde_json::to_value(&user).unwrap(); + assert!(v.get("id").is_none()); + assert!(v.get("meta").is_none()); + let v = serde_json::to_value(ScimPatchOp::default()).unwrap(); + assert!(v.get("id").is_none()); + assert!(v.get("meta").is_none()); +} diff --git a/crates/clickhouse-cloud-api/tests/models/services.rs b/crates/clickhouse-cloud-api/tests/models/services.rs new file mode 100644 index 00000000..63d0ff0f --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/services.rs @@ -0,0 +1,980 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn deserialize_service() { + let json = r#"{ + "id": "11111111-2222-3333-4444-555555555555", + "name": "my-service", + "provider": "aws", + "region": "us-east-1", + "state": "running", + "tier": "production", + "clickhouseVersion": "24.1", + "endpoints": [ + { + "protocol": "nativesecure", + "host": "abc123.clickhouse.cloud", + "port": 9440 + } + ], + "minTotalMemoryGb": 24, + "maxTotalMemoryGb": 48, + "numReplicas": 3, + "idleScaling": true, + "idleTimeoutMinutes": 5, + "ipAccessList": [ + {"source": "0.0.0.0/0", "description": "Anywhere"} + ], + "createdAt": "2024-03-01T00:00:00Z", + "privateEndpointIds": [], + "isPrimary": true, + "isReadonly": false, + "releaseChannel": "default", + "hasTransparentDataEncryption": false, + "tags": [] + }"#; + let svc: Service = serde_json::from_str(json).unwrap(); + assert_eq!(svc.name.as_deref(), Some("my-service")); + assert_eq!(svc.provider, Some(ServiceProvider::Aws)); + assert_eq!(svc.region, Some(ServiceRegion::Us_east_1)); + assert_eq!(svc.state, Some(ServiceState::Running)); + #[cfg(feature = "deprecated-fields")] + assert_eq!(svc.tier, Some(ServiceTier::Production)); + assert_eq!(svc.num_replicas, Some(3)); + assert_eq!(svc.idle_scaling, Some(true)); + assert_eq!(svc.is_primary, Some(true)); +} + +#[test] +fn serialize_service_post_request() { + let req = ServicePostRequest { + name: "new-service".to_string(), + provider: ServicePostRequestProvider::Aws, + region: ServicePostRequestRegion::Us_east_1, + #[cfg(feature = "deprecated-fields")] + tier: Some(ServicePostRequestTier::Production), + #[cfg(feature = "deprecated-fields")] + min_total_memory_gb: Some(24.0), + #[cfg(feature = "deprecated-fields")] + max_total_memory_gb: Some(48.0), + num_replicas: Some(3), + idle_scaling: Some(true), + idle_timeout_minutes: Some(5.0), + ip_access_list: vec![IpAccessListEntry { + source: "0.0.0.0/0".to_string(), + description: Some("Anywhere".to_string()), + }], + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["name"], "new-service"); + assert_eq!(json["provider"], "aws"); + assert_eq!(json["region"], "us-east-1"); + #[cfg(feature = "deprecated-fields")] + assert_eq!(json["tier"], "production"); + #[cfg(feature = "deprecated-fields")] + assert_eq!(json["minTotalMemoryGb"], 24.0); + assert_eq!(json["ipAccessList"][0]["source"], "0.0.0.0/0"); +} + +#[test] +fn serialize_service_post_request_horizontal_autoscaling() { + let req = ServicePostRequest { + name: "horizontal-service".to_string(), + autoscaling_mode: Some(AutoscalingMode::Horizontal), + min_replicas: Some(1), + max_replicas: Some(5), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["autoscalingMode"], "horizontal"); + assert_eq!(json["minReplicas"], 1); + assert_eq!(json["maxReplicas"], 5); + + // Omitted entirely when unset — mutually exclusive with the vertical + // scaling fields, so they must not serialize as null/defaults. + let json = serde_json::to_value(ServicePostRequest::default()).unwrap(); + assert!(json.get("minReplicas").is_none()); + assert!(json.get("maxReplicas").is_none()); + assert!(json.get("autoscalingMode").is_none()); +} + +#[test] +fn roundtrip_service_state_patch_request() { + let req = ServiceStatePatchRequest { + command: Some(ServiceStatePatchRequestCommand::Start), + }; + let json = serde_json::to_string(&req).unwrap(); + let deserialized: ServiceStatePatchRequest = serde_json::from_str(&json).unwrap(); + assert_eq!( + deserialized.command, + Some(ServiceStatePatchRequestCommand::Start) + ); +} + +#[test] +fn deserialize_usage_cost() { + let json = r#"{ + "costs": [], + "grandTotalCHC": 50.25 + }"#; + let cost: UsageCost = serde_json::from_str(json).unwrap(); + assert_eq!(cost.grand_total_chc, Some(50.25)); +} + +#[test] +fn deserialize_private_endpoint_config() { + let json = r#"{ + "endpointServiceId": "vpce-svc-123456", + "privateDnsHostname": "abc.vpce.clickhouse.cloud" + }"#; + let config: PrivateEndpointConfig = serde_json::from_str(json).unwrap(); + assert_eq!( + config.endpoint_service_id.as_deref(), + Some("vpce-svc-123456") + ); +} + +#[test] +fn deserialize_service_endpoint() { + let json = r#"{ + "protocol": "nativesecure", + "host": "abc123.clickhouse.cloud", + "port": 9440 + }"#; + let ep: ServiceEndpoint = serde_json::from_str(json).unwrap(); + assert_eq!(ep.protocol, Some(ServiceEndpointProtocol::Nativesecure)); + assert_eq!(ep.host.as_deref(), Some("abc123.clickhouse.cloud")); + assert_eq!(ep.port, Some(9440)); +} + +#[test] +fn api_integer_fields_round_trip_as_json_integers() { + let response: ApiResponse = serde_json::from_value(serde_json::json!({ + "status": 200, + "result": { + "numReplicas": 3, + "minReplicas": 1, + "maxReplicas": 5, + "endpoints": [{"port": 9440}] + } + })) + .unwrap(); + + let rendered = serde_json::to_string(&response).unwrap(); + assert!( + !rendered.contains(".0"), + "unexpected float JSON: {rendered}" + ); + let json = serde_json::to_value(response).unwrap(); + assert!(json["status"].is_i64()); + assert!(json["result"]["numReplicas"].is_i64()); + assert!(json["result"]["minReplicas"].is_i64()); + assert!(json["result"]["maxReplicas"].is_i64()); + assert!(json["result"]["endpoints"][0]["port"].is_i64()); + assert_eq!(json["status"], 200); + assert_eq!(json["result"]["numReplicas"], 3); + assert_eq!(json["result"]["endpoints"][0]["port"], 9440); +} + +#[test] +fn api_integer_fields_tolerate_missing_and_null_values() { + let missing: ApiResponse = serde_json::from_str("{}").unwrap(); + assert_eq!( + serde_json::to_value(missing).unwrap(), + serde_json::json!({}) + ); + let missing_endpoint: ServiceEndpoint = serde_json::from_str("{}").unwrap(); + assert_eq!(missing_endpoint.port, None); + + let nulled: ApiResponse = serde_json::from_value(serde_json::json!({ + "status": null, + "result": { + "numReplicas": null, + "minReplicas": null, + "maxReplicas": null, + "endpoints": [{"port": null}] + } + })) + .unwrap(); + assert_eq!(nulled.status, None); + let service = nulled.result.as_ref().unwrap(); + assert_eq!(service.num_replicas, None); + assert_eq!(service.min_replicas, None); + assert_eq!(service.max_replicas, None); + assert_eq!(service.endpoints.as_ref().unwrap()[0].port, None); + assert_eq!( + serde_json::to_value(nulled).unwrap(), + serde_json::json!({"result": {"endpoints": [{}]}}) + ); +} + +#[test] +fn de_facto_integer_fields_accept_integral_float_syntax() { + let response: ApiResponse = serde_json::from_value(serde_json::json!({ + "status": 200.0, + "result": {"endpoints": [{"port": 9440.0}]} + })) + .unwrap(); + assert_eq!(response.status, Some(200)); + assert_eq!( + response + .result + .as_ref() + .unwrap() + .endpoints + .as_ref() + .unwrap()[0] + .port, + Some(9440) + ); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({"status": 200, "result": {"endpoints": [{"port": 9440}]}}) + ); + + assert!( + serde_json::from_value::>(serde_json::json!({"status": 200.5})) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({"port": 9440.5})).is_err() + ); + assert!( + serde_json::from_str::>(r#"{"status":9007199254740993.0}"#).is_err() + ); +} + +#[test] +fn service_provider_enum_values() { + let aws: ServiceProvider = serde_json::from_str(r#""aws""#).unwrap(); + let gcp: ServiceProvider = serde_json::from_str(r#""gcp""#).unwrap(); + let azure: ServiceProvider = serde_json::from_str(r#""azure""#).unwrap(); + assert_eq!(aws, ServiceProvider::Aws); + assert_eq!(gcp, ServiceProvider::Gcp); + assert_eq!(azure, ServiceProvider::Azure); +} + +#[test] +fn service_state_enum_roundtrip() { + let states = [ + ("starting", ServiceState::Starting), + ("stopping", ServiceState::Stopping), + ("running", ServiceState::Running), + ("stopped", ServiceState::Stopped), + ("idle", ServiceState::Idle), + ]; + for (json_val, expected) in states { + let parsed: ServiceState = serde_json::from_str(&format!(r#""{json_val}""#)).unwrap(); + assert_eq!(parsed, expected); + + let serialized = serde_json::to_string(&expected).unwrap(); + assert_eq!(serialized, format!(r#""{json_val}""#)); + } +} + +#[test] +fn default_response_struct_has_no_values() { + // A response model's `Default` means "nothing was returned", not a set of + // fabricated zero values. + let svc = Service::default(); + assert_eq!(svc.id, None); + assert_eq!(svc.name, None); + assert_eq!(svc.provider, None); + assert_eq!(svc.state, None); +} + +#[test] +fn unknown_enum_variant_deserializes() { + // An unknown service state from the API should deserialize into Unknown(String) + let json = r#"{"state": "brand-new-state"}"#; + let svc: Service = serde_json::from_str(json).unwrap(); + assert_eq!( + svc.state, + Some(ServiceState::Unknown("brand-new-state".to_string())) + ); +} + +#[test] +fn unknown_enum_variant_roundtrips() { + let state = ServiceState::Unknown("future-state".to_string()); + let json = serde_json::to_string(&state).unwrap(); + assert_eq!(json, r#""future-state""#); + let back: ServiceState = serde_json::from_str(&json).unwrap(); + assert_eq!(back, state); +} + +#[test] +fn known_enum_variant_still_deserializes() { + let json = r#""running""#; + let state: ServiceState = serde_json::from_str(json).unwrap(); + assert_eq!(state, ServiceState::Running); +} + +#[test] +fn unknown_enum_display() { + assert_eq!(ServiceState::Running.to_string(), "running"); + assert_eq!( + ServiceState::Unknown("brand-new".to_string()).to_string(), + "brand-new" + ); +} + +#[test] +fn serialize_service_patch_request() { + let req = ServicePatchRequest { + name: Some("renamed".to_string()), + release_channel: Some(ServicePatchRequestReleasechannel::Default), + enable_core_dumps: Some(false), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["name"], "renamed"); + assert_eq!(json["releaseChannel"], "default"); + assert_eq!(json["enableCoreDumps"], false); + // None fields must be omitted + assert!(json.get("ipAccessList").is_none()); + assert!(json.get("privateEndpointIds").is_none()); + assert!(json.get("endpoints").is_none()); + assert!(json.get("tags").is_none()); +} + +#[test] +fn serialize_service_state_patch_request_start() { + let req = ServiceStatePatchRequest { + command: Some(ServiceStatePatchRequestCommand::Start), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["command"], "start"); +} + +#[test] +fn serialize_service_state_patch_request_stop() { + let req = ServiceStatePatchRequest { + command: Some(ServiceStatePatchRequestCommand::Stop), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["command"], "stop"); +} + +#[test] +fn serialize_service_replica_scaling_patch_request() { + let req = ServiceReplicaScalingPatchRequest { + num_replicas: Some(5), + min_replicas: None, + max_replicas: None, + min_replica_memory_gb: Some(16.0), + max_replica_memory_gb: Some(64.0), + idle_scaling: Some(true), + idle_timeout_minutes: Some(10.0), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["numReplicas"], 5); + assert_eq!(json["minReplicaMemoryGb"], 16.0); + assert_eq!(json["maxReplicaMemoryGb"], 64.0); + assert_eq!(json["idleScaling"], true); + assert_eq!(json["idleTimeoutMinutes"], 10.0); +} + +#[test] +fn serialize_service_scaling_patch_request() { + let req = ServiceScalingPatchRequest { + num_replicas: Some(3), + #[cfg(feature = "deprecated-fields")] + min_total_memory_gb: Some(24.0), + #[cfg(feature = "deprecated-fields")] + max_total_memory_gb: Some(48.0), + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["numReplicas"], 3); + #[cfg(feature = "deprecated-fields")] + assert_eq!(json["minTotalMemoryGb"], 24.0); + #[cfg(feature = "deprecated-fields")] + assert_eq!(json["maxTotalMemoryGb"], 48.0); + assert!(json.get("idleScaling").is_none()); +} + +#[test] +fn serialize_service_password_patch_request_default() { + let req = ServicePasswordPatchRequest::default(); + let json = serde_json::to_value(&req).unwrap(); + // All fields should be omitted, leaving just {} + assert_eq!(json, serde_json::json!({})); +} + +#[test] +fn serialize_instance_query_endpoint_post_request() { + let req = InstanceServiceQueryApiEndpointsPostRequest { + allowed_origins: Some("https://example.com".to_string()), + roles: vec![QueryEndpointRole::SqlConsoleReadOnly], + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["allowedOrigins"], "https://example.com"); + assert_eq!(json["roles"], serde_json::json!(["sql_console_read_only"])); +} + +#[test] +fn deserialize_query_endpoint_roles_tolerates_unknown_values() { + let endpoint: ServiceQueryAPIEndpoint = serde_json::from_value(serde_json::json!({ + "roles": ["sql_console_admin", "future_role"] + })) + .unwrap(); + + assert_eq!( + endpoint.roles, + Some(vec![ + QueryEndpointRole::SqlConsoleAdmin, + QueryEndpointRole::Unknown("future_role".to_string()), + ]) + ); +} + +#[test] +fn serialize_servic_private_endpointe_post_request() { + let req = ServicPrivateEndpointePostRequest { + id: "vpce-abc".to_string(), + description: Some("My PE".to_string()), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["id"], "vpce-abc"); + assert_eq!(json["description"], "My PE"); +} + +#[test] +fn deserialize_scaling_schedule_entry_fixed_scaling_fields() { + let json = r#"{ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "weekday-peak", + "weekdays": [1, 2, 3, 4, 5], + "startHourUtc": 8, + "endHourUtc": 18, + "isActiveNow": false, + "autoscalingMode": "vertical", + "minReplicaMemoryGb": 16, + "maxReplicaMemoryGb": 32 + }"#; + let entry: ScalingScheduleEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.autoscaling_mode, Some(AutoscalingMode::Vertical)); + assert_eq!(entry.min_replica_memory_gb, Some(16.0)); + assert_eq!(entry.max_replica_memory_gb, Some(32.0)); + + // Writing a fetched entry back goes through the explicit conversion, which + // resolves the fields the request requires. + let req = ScalingScheduleEntryRequest::try_from(entry).unwrap(); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["autoscalingMode"], "vertical"); + assert_eq!(json["minReplicaMemoryGb"], 16.0); + assert_eq!(json["maxReplicaMemoryGb"], 32.0); +} + +#[test] +fn service_ignores_extra_fields() { + let json = r#"{"name":"svc","state":"running","futureField":"v2","nested":{"a":1}}"#; + let svc: Service = serde_json::from_str(json).unwrap(); + assert_eq!(svc.name.as_deref(), Some("svc")); + assert_eq!(svc.state, Some(ServiceState::Running)); +} + +#[test] +fn service_minimal_response() { + let json = r#"{"id":"11111111-2222-3333-4444-555555555555"}"#; + let svc: Service = serde_json::from_str(json).unwrap(); + assert_eq!( + svc.id, + Some( + "11111111-2222-3333-4444-555555555555" + .parse::() + .unwrap() + ) + ); + // Every response field is `Option`: an omitted key is `None`, not a + // fabricated zero value. + assert_eq!(svc.name, None); + assert_eq!(svc.provider, None); + assert_eq!(svc.state, None); + assert_eq!(svc.endpoints, None); +} + +#[cfg(feature = "deprecated-fields")] +#[test] +fn service_deserializes_deprecated_fields() { + // With the `deprecated-fields` feature on, deprecated fields exist on the + // struct and deserialize normally. Without the feature they are absent from + // the struct entirely (see `deprecated_fields_absent_by_default`). + let json = r#"{"tier":"production","minTotalMemoryGb":24,"maxTotalMemoryGb":48}"#; + let svc: Service = serde_json::from_str(json).unwrap(); + assert_eq!(svc.min_total_memory_gb, Some(24.0)); + assert_eq!(svc.max_total_memory_gb, Some(48.0)); +} + +#[test] +#[cfg(not(feature = "deprecated-fields"))] +fn service_hides_deprecated_fields_when_serializing() { + let svc: Service = serde_json::from_str( + r#"{"name":"svc","tier":"production","minTotalMemoryGb":24,"maxTotalMemoryGb":48}"#, + ) + .unwrap(); + let value = serde_json::to_value(&svc).unwrap(); + let obj = value.as_object().unwrap(); + // Deprecated fields are omitted from serialized output by default. + assert!(!obj.contains_key("tier"), "tier should be hidden"); + assert!(!obj.contains_key("minTotalMemoryGb")); + assert!(!obj.contains_key("maxTotalMemoryGb")); + // Non-deprecated fields are still present. + assert_eq!(obj.get("name").and_then(|v| v.as_str()), Some("svc")); +} + +#[test] +#[cfg(feature = "deprecated-fields")] +fn service_shows_deprecated_fields_with_feature() { + let svc: Service = serde_json::from_str( + r#"{"tier":"production","minTotalMemoryGb":24,"maxTotalMemoryGb":48}"#, + ) + .unwrap(); + let value = serde_json::to_value(&svc).unwrap(); + let obj = value.as_object().unwrap(); + assert!(obj.contains_key("tier")); + assert!(obj.contains_key("minTotalMemoryGb")); + assert!(obj.contains_key("maxTotalMemoryGb")); +} + +#[test] +fn service_empty_object() { + let svc: Service = serde_json::from_str("{}").unwrap(); + assert_eq!(svc, Service::default()); + assert_eq!(svc.id, None); + assert_eq!(svc.name, None); +} + +#[test] +fn service_response_tolerates_dropped_and_null_fields() { + // A response field the API stops sending, and one it sends as an explicit + // `null`, must both land as `None` rather than failing the response. `null` + // is the case `#[serde(default)]` never covered: it only fills a missing + // key. + let dropped: Service = serde_json::from_str("{}").unwrap(); + let nulled: Service = serde_json::from_str( + r#"{"id":null,"name":null,"state":null,"endpoints":null,"ipAccessList":null, + "tags":null,"currentScaling":null,"scalingSchedule":null,"numReplicas":null}"#, + ) + .unwrap(); + assert_eq!(dropped, Service::default()); + assert_eq!(nulled, Service::default()); + assert_eq!(nulled.endpoints, None); + assert_eq!(nulled.ip_access_list, None); + assert_eq!(nulled.tags, None); +} + +#[test] +fn scaling_schedule_entry_response_converts_back_into_a_request_entry() { + let entry = ScalingScheduleEntry { + name: Some("weekday-peak".to_string()), + weekdays: Some(vec![1, 2, 3]), + start_hour_utc: Some(8), + end_hour_utc: Some(18), + autoscaling_mode: Some(AutoscalingMode::Vertical), + min_replica_memory_gb: Some(16.0), + ..Default::default() + }; + let request = ScalingScheduleEntryRequest::try_from(entry).unwrap(); + assert_eq!(request.name, "weekday-peak"); + assert_eq!(request.weekdays, vec![1, 2, 3]); + assert_eq!(request.start_hour_utc, 8); + assert_eq!(request.end_hour_utc, 18); + assert_eq!(request.min_replica_memory_gb, Some(16.0)); + + // An upsert replaces the whole schedule, so an entry the API returned + // without its window bounds, weekdays or name cannot be re-sent. + let missing = + ScalingScheduleEntryRequest::try_from(ScalingScheduleEntry::default()).unwrap_err(); + assert_eq!( + missing.fields(), + ["endHourUtc", "name", "startHourUtc", "weekdays"] + ); +} + +#[test] +fn upgrade_window_response_converts_back_into_a_put_body() { + let request = UpgradeWindowPutRequest::try_from(UpgradeWindow { + // `duration` is response-only and does not cross over. + duration: Some(UpgradeWindowDuration::SixHours), + start_hour_utc: Some(UpgradeWindowStartHourUtc::Hour6), + weekday: Some(2), + }) + .unwrap(); + assert_eq!(request.start_hour_utc, UpgradeWindowStartHourUtc::Hour6); + assert_eq!(request.weekday, 2); + + let missing = UpgradeWindowPutRequest::try_from(UpgradeWindow::default()).unwrap_err(); + assert_eq!(missing.fields(), ["startHourUtc", "weekday"]); +} + +#[test] +fn deserialize_service_post_response() { + let json = r#"{ + "service": { + "id": "11111111-2222-3333-4444-555555555555", + "name": "new-svc", + "state": "provisioning" + }, + "password": "gen-pw-123" + }"#; + let resp: ServicePostResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.password.as_deref(), Some("gen-pw-123")); + let service = resp.service.unwrap(); + assert_eq!(service.name.as_deref(), Some("new-svc")); + assert_eq!(service.state, Some(ServiceState::Provisioning)); +} + +#[test] +fn deserialize_usage_cost_with_records() { + let json = r#"{ + "costs": [ + { + "name": "compute", + "totalCHC": 25.5 + }, + { + "name": "storage", + "totalCHC": 10.0 + } + ], + "grandTotalCHC": 35.5 + }"#; + let cost: UsageCost = serde_json::from_str(json).unwrap(); + assert_eq!(cost.grand_total_chc, Some(35.5)); + assert_eq!(cost.costs.as_deref().map(<[_]>::len), Some(2)); +} + +// =========================================================================== +// Upgrade window (issue #203 drift) +// =========================================================================== + +#[test] +fn deserialize_upgrade_window() { + let json = r#"{ + "weekday": 2, + "startHourUtc": 6, + "duration": 6 + }"#; + let w: UpgradeWindow = serde_json::from_str(json).unwrap(); + assert_eq!(w.weekday, Some(2)); + assert_eq!(w.start_hour_utc, Some(UpgradeWindowStartHourUtc::Hour6)); + assert_eq!(w.duration, Some(UpgradeWindowDuration::SixHours)); + + let round_tripped = serde_json::to_value(&w).unwrap(); + assert_eq!(round_tripped["startHourUtc"], 6); + assert_eq!(round_tripped["weekday"], 2); + assert_eq!(round_tripped["duration"], 6); +} + +#[test] +fn upgrade_window_numeric_enums_round_trip_every_known_value() { + for (wire, value) in [ + (0, UpgradeWindowStartHourUtc::Hour0), + (6, UpgradeWindowStartHourUtc::Hour6), + (12, UpgradeWindowStartHourUtc::Hour12), + (18, UpgradeWindowStartHourUtc::Hour18), + ] { + assert_eq!(serde_json::to_value(value).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire.into()).unwrap(), + value + ); + } + + assert_eq!( + serde_json::to_value(UpgradeWindowDuration::SixHours).unwrap(), + 6 + ); + assert_eq!( + serde_json::from_value::(serde_json::json!(6)).unwrap(), + UpgradeWindowDuration::SixHours + ); +} + +#[test] +fn deserialize_upgrade_window_tolerates_unknown_numeric_values() { + let window: UpgradeWindow = serde_json::from_value(serde_json::json!({ + "startHourUtc": 3, + "duration": 21600 + })) + .unwrap(); + + assert_eq!( + window.start_hour_utc, + Some(UpgradeWindowStartHourUtc::Unknown(3)) + ); + assert_eq!(window.duration, Some(UpgradeWindowDuration::Unknown(21600))); + assert_eq!( + serde_json::to_value(window).unwrap(), + serde_json::json!({"startHourUtc": 3, "duration": 21600}) + ); +} + +#[test] +fn serialize_upgrade_window_put_request() { + let req = UpgradeWindowPutRequest { + weekday: 5, + start_hour_utc: UpgradeWindowStartHourUtc::Hour18, + }; + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v["weekday"], 5); + assert_eq!(v["startHourUtc"], 18); + assert!(v.get("start_hour_utc").is_none()); +} + +#[test] +fn autoscaling_mode_round_trip() { + let v = serde_json::to_value(AutoscalingMode::Vertical).unwrap(); + assert_eq!(v, "vertical"); + let v = serde_json::to_value(AutoscalingMode::Horizontal).unwrap(); + assert_eq!(v, "horizontal"); + let parsed: AutoscalingMode = serde_json::from_str("\"vertical\"").unwrap(); + assert_eq!(parsed, AutoscalingMode::Vertical); + let parsed: AutoscalingMode = serde_json::from_str("\"horizontal\"").unwrap(); + assert_eq!(parsed, AutoscalingMode::Horizontal); + assert_eq!(AutoscalingMode::default(), AutoscalingMode::Vertical); + assert_eq!(AutoscalingMode::Vertical.to_string(), "vertical"); + assert_eq!(AutoscalingMode::Horizontal.to_string(), "horizontal"); +} + +#[test] +fn autoscaling_mode_unknown_catch_all() { + let parsed: AutoscalingMode = serde_json::from_str("\"crystal\"").unwrap(); + assert_eq!(parsed, AutoscalingMode::Unknown("crystal".to_string())); + assert_eq!(parsed.to_string(), "crystal"); +} + +#[test] +fn service_region_ca_central_1() { + let parsed: ServiceRegion = serde_json::from_str("\"ca-central-1\"").unwrap(); + assert_eq!(parsed, ServiceRegion::Ca_central_1); + assert_eq!(parsed.to_string(), "ca-central-1"); +} + +#[test] +fn service_clickhouse_settings_typed_request_preserves_native_values() { + let settings = serde_json::json!({ + "compatibility": "26.2", "max_query_size": 262146, + "mark_cache_ram_ratio": "0.5", "signed": -42, + "unsigned": u64::MAX + }); + let map: ServiceClickhouseSettingsMap = serde_json::from_value(settings.clone()).unwrap(); + assert_eq!( + map["compatibility"], + ServiceClickhouseSettingValue::String("26.2".into()) + ); + assert_eq!( + map["max_query_size"], + ServiceClickhouseSettingValue::Integer(262146) + ); + assert_eq!( + map["unsigned"], + ServiceClickhouseSettingValue::UnsignedInteger(u64::MAX) + ); + let request = ServiceClickhouseSettingsPatchRequest { + settings: Some(map), + }; + assert_eq!( + serde_json::to_value(request).unwrap(), + serde_json::json!({"settings": settings}) + ); + let empty = ServiceClickhouseSettingsPatchRequest { + settings: Some(ServiceClickhouseSettingsMap::new()), + }; + assert!(serde_json::to_value(empty).is_err()); +} + +#[test] +fn service_clickhouse_settings_typed_request_rejects_unsupported_value_types() { + for value in [ + serde_json::json!(true), + serde_json::Value::Null, + serde_json::json!(1.5), + serde_json::json!({"nested": true}), + serde_json::json!([1]), + ] { + assert!( + serde_json::from_value::( + serde_json::json!({"setting": value}) + ) + .is_err() + ); + } +} + +#[test] +fn service_clickhouse_settings_preserves_dynamic_json_values() { + let settings = serde_json::json!({ + "string": "26.2", "integer": 262144, "boolean": false, "null": null, + "array": [1, "two"], "future": {"nested": true} + }); + let request: ServiceClickhouseSettingsPatchRequest = + serde_json::from_value(serde_json::json!({"settings": settings})).unwrap(); + assert_eq!( + serde_json::to_value(request).unwrap(), + serde_json::json!({"settings": settings}) + ); + let response: ServiceClickhouseSettingsPatchResponse = + serde_json::from_value(serde_json::json!({"settings": settings})).unwrap(); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({"settings": settings}) + ); + for sparse in [ + serde_json::json!({}), + serde_json::json!({"settings": null, "warnings": null}), + ] { + let response: ServiceClickhouseSettingsPatchResponse = + serde_json::from_value(sparse).unwrap(); + assert!(response.settings.is_none()); + assert!(response.warnings.is_none()); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({}) + ); + } + let empty = ServiceClickhouseSettingsPatchRequest { + settings: Some(std::collections::BTreeMap::::new()), + }; + assert!(serde_json::to_value(empty).is_err()); +} + +#[test] +fn service_clickhouse_setting_preserves_json_value_types() { + for value in [ + serde_json::json!(262146), + serde_json::json!(18446744073709551615_u64), + serde_json::json!(-42), + serde_json::json!(1.25), + serde_json::json!("26.2"), + serde_json::json!("262146"), + serde_json::json!(false), + serde_json::json!({"future": [1, true]}), + ] { + let wire = serde_json::json!({"name": "setting", "value": value}); + let setting: ServiceClickhouseSetting = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(setting.value.as_ref(), Some(&value)); + assert_eq!(serde_json::to_value(&setting).unwrap(), wire); + let list_wire = serde_json::json!({"settings": [wire]}); + let list: ServiceClickhouseSettingsList = + serde_json::from_value(list_wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(list).unwrap(), list_wire); + } +} + +#[test] +fn service_clickhouse_setting_missing_and_null_values_remain_absent() { + for wire in [ + serde_json::json!({}), + serde_json::json!({"name": null, "value": null}), + ] { + let setting: ServiceClickhouseSetting = serde_json::from_value(wire).unwrap(); + assert_eq!(setting.name, None); + assert_eq!(setting.value, None); + assert_eq!( + serde_json::to_value(setting).unwrap(), + serde_json::json!({}) + ); + } +} + +fn sample_backup_encryption_config() -> serde_json::Value { + serde_json::json!({ + "schema_version": 1, + "restore_key_pairs": [ + { + "customer_managed_encryption_key": { + "aws_kms_key_arn": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" + }, + "encrypted_dek": "d3JhcHBlZA==", + "future_field": null + } + ], + "provider_extension": {"nested": [1, 2.5, true, "x"]} + }) +} + +#[test] +fn backup_encryption_config_round_trips_opaque_nested_contents() { + let file = sample_backup_encryption_config(); + let config: BackupEncryptionConfig = serde_json::from_value(file.clone()).unwrap(); + assert_eq!(config["schema_version"], 1); + assert_eq!( + config["restore_key_pairs"][0]["customer_managed_encryption_key"]["aws_kms_key_arn"], + "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" + ); + assert_eq!(serde_json::to_value(&config).unwrap(), file); + + // The file is an object; a non-object is not a valid config. + assert!(serde_json::from_value::(serde_json::json!([1])).is_err()); +} + +#[test] +fn service_post_request_backup_encryption_config_serialization() { + let base = ServicePostRequest { + name: "restored".to_string(), + provider: ServicePostRequestProvider::Aws, + region: ServicePostRequestRegion::Us_east_1, + backup_id: Some(uuid::Uuid::nil()), + ..Default::default() + }; + let json = serde_json::to_value(&base).unwrap(); + assert!(json.get("backupEncryptionConfig").is_none()); + + let file = sample_backup_encryption_config(); + let req = ServicePostRequest { + backup_encryption_config: Some(serde_json::from_value(file.clone()).unwrap()), + ..base + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["backupEncryptionConfig"], file); + assert_eq!(json["backupId"], uuid::Uuid::nil().to_string()); + + let parsed: ServicePostRequest = serde_json::from_value(json).unwrap(); + assert_eq!(parsed, req); +} + +#[test] +fn replica_aware_routing_missing_and_null_fields_are_none() { + let missing: ReplicaAwareRouting = serde_json::from_str("{}").unwrap(); + assert_eq!(missing, ReplicaAwareRouting::default()); + assert_eq!( + serde_json::to_value(&missing).unwrap(), + serde_json::json!({}) + ); + + let null: ReplicaAwareRouting = serde_json::from_value(serde_json::json!({ + "httpEnabled": null, "nativeEnabled": null + })) + .unwrap(); + assert_eq!(null, ReplicaAwareRouting::default()); +} + +#[test] +fn serialize_replica_aware_routing_patch_request() { + assert_eq!( + serde_json::to_value(ReplicaAwareRoutingPatchRequest::default()).unwrap(), + serde_json::json!({}) + ); + assert_eq!( + serde_json::to_value(ReplicaAwareRoutingPatchRequest { + http_enabled: Some(false), + native_enabled: None, + }) + .unwrap(), + serde_json::json!({"httpEnabled": false}) + ); + assert_eq!( + serde_json::to_value(ReplicaAwareRoutingPatchRequest { + http_enabled: Some(true), + native_enabled: Some(false), + }) + .unwrap(), + serde_json::json!({"httpEnabled": true, "nativeEnabled": false}) + ); +} diff --git a/crates/clickhouse-cloud-api/tests/models/shared.rs b/crates/clickhouse-cloud-api/tests/models/shared.rs new file mode 100644 index 00000000..415215cd --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/shared.rs @@ -0,0 +1,197 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn deserialize_api_response_with_org_list() { + let json = r#"{ + "status": 200, + "requestId": "req-uuid-123", + "result": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Org 1" + }, + { + "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "name": "Org 2" + } + ] + }"#; + let resp: ApiResponse> = serde_json::from_str(json).unwrap(); + assert_eq!(resp.status, Some(200)); + assert_eq!(resp.request_id, Some("req-uuid-123".to_string())); + let result = resp.result.unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].name.as_deref(), Some("Org 1")); + assert_eq!(result[1].name.as_deref(), Some("Org 2")); +} + +#[test] +fn deserialize_api_response_error() { + let json = r#"{ + "status": 401, + "error": "Unauthorized", + "requestId": "req-uuid-456" + }"#; + let resp: ApiResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.status, Some(401)); + assert_eq!(resp.error, Some("Unauthorized".to_string())); + assert!(resp.result.is_none()); +} + +// =========================================================================== +// ApiResponse envelope edge cases +// =========================================================================== + +#[test] +fn api_response_result_explicitly_null() { + let json = r#"{"status": 200, "requestId": "req-1", "result": null}"#; + let resp: ApiResponse> = serde_json::from_str(json).unwrap(); + assert_eq!(resp.status, Some(200)); + assert!(resp.result.is_none()); +} + +#[test] +fn api_response_missing_status() { + let json = r#"{"result": []}"#; + let resp: ApiResponse> = serde_json::from_str(json).unwrap(); + assert!(resp.status.is_none()); + assert!(resp.request_id.is_none()); + assert_eq!(resp.result.unwrap().len(), 0); +} + +#[test] +fn api_response_extra_fields_ignored() { + let json = r#"{ + "status": 200, + "requestId": "req-1", + "result": {"name": "Test"}, + "extraField": true, + "anotherField": 42, + "nestedExtra": {"a": 1} + }"#; + let resp: ApiResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.status, Some(200)); + let org = resp.result.unwrap(); + assert_eq!(org.name.as_deref(), Some("Test")); +} + +#[test] +fn api_response_empty_object() { + let json = r#"{}"#; + let resp: ApiResponse = serde_json::from_str(json).unwrap(); + assert!(resp.status.is_none()); + assert!(resp.request_id.is_none()); + assert!(resp.result.is_none()); + assert!(resp.error.is_none()); +} + +#[test] +fn service_response_omits_absent_fields_when_serialized() { + // Absent means absent, at every level of the response tree: a field that + // was not returned is omitted from `--json` output, never emitted as + // `null`. + let svc = Service { + name: Some("svc".to_string()), + ip_access_list: Some(vec![IpAccessListEntryResponse { + source: Some("0.0.0.0/0".to_string()), + description: None, + }]), + tags: Some(vec![ResourceTagsV1Response { + key: Some("env".to_string()), + value: None, + }]), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&svc).unwrap(), + serde_json::json!({ + "name": "svc", + "ipAccessList": [{ "source": "0.0.0.0/0" }], + "tags": [{ "key": "env" }], + }) + ); +} + +#[test] +fn shared_leaves_stay_strict_on_the_request_side() { + // `ipAccessListEntry` and `resourceTagsV1` are sent as well as returned, so + // each splits: the request variant keeps the schema's required fields as + // `T`, and only the response variant is all-`Option`. + let entry = IpAccessListEntry { + source: "0.0.0.0/0".to_string(), + description: None, + }; + assert_eq!( + serde_json::to_value(&entry).unwrap(), + serde_json::json!({ "source": "0.0.0.0/0" }) + ); + // A request payload missing a required field is rejected, not defaulted. + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::("{}").is_err()); + // The response variants accept the same payload. + assert_eq!( + serde_json::from_str::("{}").unwrap(), + IpAccessListEntryResponse::default() + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ResourceTagsV1Response::default() + ); +} + +#[test] +fn resource_tag_response_converts_back_into_a_request_tag() { + let tag = ResourceTagsV1::try_from(ResourceTagsV1Response { + key: Some("env".to_string()), + value: Some("dev".to_string()), + }) + .unwrap(); + assert_eq!(tag.key, "env"); + assert_eq!(tag.value.as_deref(), Some("dev")); + + // A tag is identified by its key, so a keyless response tag cannot be + // written back. + let missing = ResourceTagsV1::try_from(ResourceTagsV1Response { + key: None, + value: Some("dev".to_string()), + }) + .unwrap_err(); + assert_eq!(missing.fields(), ["key"]); +} + +#[test] +fn api_response_pagination_tolerates_missing_null_and_unknown_fields() { + for value in [ + serde_json::json!({}), + serde_json::json!({ + "limit": null, "totalCount": null, "nextCursor": null, "futureMetadata": true + }), + ] { + let response: ApiResponse> = serde_json::from_value(value).unwrap(); + assert_eq!(response.limit, None); + assert_eq!(response.total_count, None); + assert_eq!(response.next_cursor, None); + assert_eq!( + serde_json::to_value(response).unwrap(), + serde_json::json!({}) + ); + } +} + +#[test] +fn api_response_pagination_round_trips_values_and_omits_last_page_cursor() { + for (total_count, next_cursor) in [(0, None), (500, Some("opaque +/=&?雪"))] { + let mut value = serde_json::json!({ + "status": 200, "requestId": "req-page", "result": [], + "limit": 250, "totalCount": total_count + }); + if let Some(cursor) = next_cursor { + value["nextCursor"] = serde_json::json!(cursor); + } + let response: ApiResponse> = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(response.limit, Some(250)); + assert_eq!(response.total_count, Some(total_count)); + assert_eq!(response.next_cursor.as_deref(), next_cursor); + assert_eq!(serde_json::to_value(response).unwrap(), value); + } +} diff --git a/crates/clickhouse-cloud-api/tests/models/udfs.rs b/crates/clickhouse-cloud-api/tests/models/udfs.rs new file mode 100644 index 00000000..35b75c56 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/udfs.rs @@ -0,0 +1,148 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn udf_argument_output_preserves_response_tolerance_and_old_name() { + for value in [ + serde_json::json!({}), + serde_json::json!({"name": null, "type": null}), + ] { + let argument: UdfArgumentOutput = serde_json::from_value(value).unwrap(); + let old_name: UdfArgumentResponse = argument.clone(); + assert_eq!(old_name, UdfArgumentOutput::default()); + assert_eq!( + serde_json::to_value(argument).unwrap(), + serde_json::json!({}) + ); + } + let value = serde_json::json!({"name": "x", "type": "UInt64"}); + let argument: UdfArgumentOutput = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(argument).unwrap(), value); + for invalid in [ + serde_json::json!({}), + serde_json::json!({"name": "x"}), + serde_json::json!({"type": "UInt64"}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } +} + +#[test] +fn udf_responses_tolerate_dropped_and_null_fields() { + let dropped: Udf = serde_json::from_str("{}").unwrap(); + let nulled: Udf = serde_json::from_str( + r#"{"functionName":null,"runtime":null,"arguments":null,"createdAt":null,"memoryLimitMib":null}"#, + ) + .unwrap(); + + assert_eq!(dropped, Udf::default()); + assert_eq!(nulled, Udf::default()); + assert_eq!(serde_json::to_value(nulled).unwrap(), serde_json::json!({})); +} + +#[test] +fn udf_response_models_tolerate_absent_and_null_fields() { + // These models are reached only from Client response types. A dropped key + // and an explicit null must therefore both deserialize to None, and None + // must be omitted again when rendered as JSON. + macro_rules! assert_tolerant_response { + ($response:ty, $null_payload:literal) => {{ + let dropped: $response = serde_json::from_str("{}").unwrap(); + let nulled: $response = serde_json::from_str($null_payload).unwrap(); + assert_eq!(dropped, <$response>::default()); + assert_eq!(nulled, <$response>::default()); + assert_eq!(serde_json::to_value(nulled).unwrap(), serde_json::json!({})); + }}; + } + + assert_tolerant_response!( + Pagination, + r#"{"totalRecords":null,"currentCursor":null,"nextCursor":null,"limit":null}"# + ); + assert_tolerant_response!( + UdfAttachment, + r#"{"functionName":null,"serviceId":null,"status":null,"version":null}"# + ); + assert_tolerant_response!( + UdfAttachmentListResponse, + r#"{"items":null,"pagination":null}"# + ); + assert_tolerant_response!(UdfListResponse, r#"{"items":null,"pagination":null}"#); + assert_tolerant_response!( + UdfUploadSession, + r#"{"uploadId":null,"uploadUrl":null,"expiresAt":null}"# + ); + assert_tolerant_response!( + UdfVersionListResponse, + r#"{"items":null,"pagination":null}"# + ); +} + +#[test] +fn udf_create_request_variants_keep_required_fields_strict() { + // The discriminator is intentionally lossless at the union boundary. Once + // it selects a known request shape, serde rejects a body missing a required + // field rather than inventing a default value. + assert!( + serde_json::from_str::( + r#"{"arguments":[],"functionName":"my_udf","returnType":"String","runtime":"native","type":"executable"}"#, + ) + .is_err() + ); + assert!( + serde_json::from_str::( + r#"{"arguments":[],"functionName":"my_udf","returnType":"String","runtime":"native","type":"executable_pool"}"#, + ) + .is_err() + ); + + let request = UdfCreateRequestV1 { + arguments: vec![UdfArgument { + name: "x".to_string(), + r#type: "UInt64".to_string(), + }], + function_name: "my_udf".to_string(), + return_type: "UInt64".to_string(), + runtime: UdfRuntime::Native, + r#type: UdfCreateRequestV1Type::Executable, + upload_id: "upload-1".to_string(), + ..Default::default() + }; + assert_eq!( + serde_json::to_value(request).unwrap(), + serde_json::json!({ + "arguments": [{"name": "x", "type": "UInt64"}], + "functionName": "my_udf", + "returnType": "UInt64", + "runtime": "native", + "type": "executable", + "uploadId": "upload-1", + }) + ); +} + +#[test] +fn udf_request_inline_variants_preserve_determinism_and_memory() { + fn check(kind: &str, create: bool) { + let mut input = serde_json::json!({"uploadId": "upload-1", "runtime": "native", + "arguments": [], "returnType": "UInt64", "type": kind}); + if create { + input["functionName"] = serde_json::json!("my_udf"); + } + let minimal: T = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(minimal).unwrap(), input); + input["deterministic"] = serde_json::json!(false); + input["memoryLimitMib"] = serde_json::json!(128); + let full: T = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(full).unwrap(), input); + input["memoryLimitMib"] = serde_json::Value::Null; + let nullable: T = serde_json::from_value(input.clone()).unwrap(); + input.as_object_mut().unwrap().remove("memoryLimitMib"); + assert_eq!(serde_json::to_value(nullable).unwrap(), input); + input.as_object_mut().unwrap().remove("runtime"); + assert!(serde_json::from_value::(input).is_err()); + } + check::("executable", true); + check::("executable_pool", true); + check::("executable", false); + check::("executable_pool", false); +} diff --git a/crates/clickhouse-cloud-api/tests/models/whoami.rs b/crates/clickhouse-cloud-api/tests/models/whoami.rs new file mode 100644 index 00000000..165c9360 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/models/whoami.rs @@ -0,0 +1,89 @@ +use clickhouse_cloud_api::models::*; + +#[test] +fn whoami_variants_tolerate_missing_and_null_fields() { + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "actorType": null, "userId": null, "email": null, "name": null, + "organizations": null + }), + ] { + let user: WhoamiUser = serde_json::from_value(wire).unwrap(); + assert_eq!(user, WhoamiUser::default()); + assert_eq!(serde_json::to_value(user).unwrap(), serde_json::json!({})); + } + for wire in [ + serde_json::json!({}), + serde_json::json!({ + "actorType": null, "keyId": null, "name": null, "organizationId": null + }), + ] { + let key: WhoamiApiKey = serde_json::from_value(wire).unwrap(); + assert_eq!(key, WhoamiApiKey::default()); + assert_eq!(serde_json::to_value(key).unwrap(), serde_json::json!({})); + } + for wire in [ + serde_json::json!({}), + serde_json::json!({"organizationId": null, "organizationName": null}), + ] { + let org: WhoamiOrganization = serde_json::from_value(wire).unwrap(); + assert_eq!(org, WhoamiOrganization::default()); + assert_eq!(serde_json::to_value(org).unwrap(), serde_json::json!({})); + } +} + +#[test] +fn whoami_dispatches_on_actor_type_and_tolerates_sparse_variants() { + let user: Whoami = serde_json::from_value(serde_json::json!({"actorType": "user"})).unwrap(); + assert_eq!( + user, + Whoami::WhoamiUser(WhoamiUser { + actor_type: Some(WhoamiUserActortype::User), + ..Default::default() + }) + ); + assert_eq!(user.to_string(), "WhoamiUser"); + + // `name` is shared by both variants; dispatch must follow `actorType`. + let key: Whoami = serde_json::from_value(serde_json::json!({ + "actorType": "apiKey", "name": "ci key", "organizations": null + })) + .unwrap(); + assert_eq!( + key, + Whoami::WhoamiApiKey(WhoamiApiKey { + actor_type: Some(WhoamiApiKeyActortype::ApiKey), + name: Some("ci key".into()), + ..Default::default() + }) + ); + assert_eq!(key.to_string(), "WhoamiApiKey"); +} + +#[test] +fn whoami_keeps_unknown_absent_or_malformed_actor_types_verbatim() { + for wire in [ + serde_json::json!({"actorType": "serviceAccount", "name": "sa"}), + serde_json::json!({"name": "no discriminator"}), + serde_json::json!({"actorType": null, "keyId": "key-1"}), + // Recognized discriminator whose payload no longer fits the variant. + serde_json::json!({"actorType": "user", "organizations": "not-a-list"}), + ] { + let whoami: Whoami = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(whoami, Whoami::Unknown(wire.clone())); + assert_eq!(serde_json::to_value(&whoami).unwrap(), wire); + assert_eq!(whoami.to_string(), wire.to_string()); + } +} + +#[test] +fn whoami_actor_types_preserve_unknown_values() { + let user: WhoamiUserActortype = serde_json::from_str("\"futureUser\"").unwrap(); + assert_eq!(user, WhoamiUserActortype::Unknown("futureUser".into())); + assert_eq!(user.to_string(), "futureUser"); + assert_eq!(WhoamiUserActortype::User.to_string(), "user"); + let key: WhoamiApiKeyActortype = serde_json::from_str("\"futureKey\"").unwrap(); + assert_eq!(key, WhoamiApiKeyActortype::Unknown("futureKey".into())); + assert_eq!(WhoamiApiKeyActortype::ApiKey.to_string(), "apiKey"); +} diff --git a/crates/clickhouse-cloud-api/tests/models_test.rs b/crates/clickhouse-cloud-api/tests/models_test.rs deleted file mode 100644 index b3fd0e3a..00000000 --- a/crates/clickhouse-cloud-api/tests/models_test.rs +++ /dev/null @@ -1,11249 +0,0 @@ -use clickhouse_cloud_api::models::*; - -#[test] -fn aws_backup_external_id_response_tolerates_absence_and_null() { - for wire in [ - serde_json::json!({}), - serde_json::json!({"iamRoleExternalId": null}), - ] { - let response: AwsBackupBucket = serde_json::from_value(wire).unwrap(); - assert_eq!(response.iam_role_external_id, None); - assert_eq!( - serde_json::to_value(response).unwrap(), - serde_json::json!({}) - ); - } - let response: AwsBackupBucket = serde_json::from_value( - serde_json::json!({"iamRoleExternalId": "trust-policy-id", "futureField": 1}), - ) - .unwrap(); - assert_eq!( - response.iam_role_external_id.as_deref(), - Some("trust-policy-id") - ); -} - -#[test] -fn aws_backup_external_id_create_and_properties_are_strict() { - let mut wire = serde_json::json!({ - "bucketProvider": "AWS", "bucketPath": "s3://backups", - "iamRoleArn": "arn:aws:iam::123:role/backup", "iamRoleSessionName": "backups", - "iamRoleExternalId": "trust-policy-id" - }); - let request: AwsBackupBucketPostRequestV1 = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(request.iam_role_external_id, "trust-policy-id"); - assert_eq!(serde_json::to_value(request).unwrap(), wire); - let properties: AwsBackupBucketProperties = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(properties.iam_role_external_id, "trust-policy-id"); - wire.as_object_mut().unwrap().remove("iamRoleExternalId"); - assert!(serde_json::from_value::(wire.clone()).is_err()); - assert!(serde_json::from_value::(wire.clone()).is_err()); - wire["iamRoleExternalId"] = serde_json::Value::Null; - assert!(serde_json::from_value::(wire.clone()).is_err()); - assert!(serde_json::from_value::(wire).is_err()); -} - -#[test] -fn aws_backup_external_id_update_preserves_omit_set_and_clear() { - for external_id in [ - None, - Some(serde_json::json!("trust-policy-id")), - Some(serde_json::Value::Null), - ] { - let mut wire = serde_json::json!({ - "bucketProvider": "AWS", "bucketPath": "s3://backups", - "iamRoleArn": "arn:aws:iam::123:role/backup" - }); - if let Some(value) = &external_id { - wire["iamRoleExternalId"] = value.clone(); - } - let request: AwsBackupBucketPatchRequestV1 = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!( - request.iam_role_external_id, - external_id.map(|value| value.as_str().map(str::to_owned)) - ); - assert_eq!(serde_json::to_value(request).unwrap(), wire); - } - for wire in [ - r#"{"bucketProvider":"AWS","bucketPath":"s3://backups","iamRoleArn":"arn","iamRoleExternalId":42}"#, - r#"{"bucketProvider":"AWS","bucketPath":"s3://backups","iamRoleArn":"arn","iamRoleExternalId":null,"iamRoleExternalId":"id"}"#, - ] { - assert!(serde_json::from_str::(wire).is_err()); - } - assert!( - serde_json::from_value::(serde_json::json!({})).is_err() - ); -} - -#[test] -fn snapshot_configuration_tolerates_missing_null_and_extra_fields() { - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "enabled": null, "gap": null, "timeFrame": null, "futureProperty": 1 - }), - ] { - let configuration: SnapshotConfiguration = serde_json::from_value(wire).unwrap(); - assert_eq!(configuration, SnapshotConfiguration::default()); - assert_eq!( - serde_json::to_value(configuration).unwrap(), - serde_json::json!({}) - ); - } -} - -#[test] -fn snapshot_configuration_preserves_false_zero_and_fractional_numbers() { - let configuration: SnapshotConfiguration = serde_json::from_value(serde_json::json!({ - "enabled": false, "gap": 0, "timeFrame": 1440.5 - })) - .unwrap(); - assert_eq!(configuration.enabled, Some(false)); - assert_eq!(configuration.gap, Some(0.0)); - assert_eq!(configuration.time_frame, Some(1440.5)); - assert_eq!( - serde_json::to_value(configuration).unwrap(), - serde_json::json!({"enabled": false, "gap": 0.0, "timeFrame": 1440.5}) - ); -} - -#[test] -fn snapshot_configuration_patch_omits_none_and_preserves_provided_values() { - for (request, expected) in [ - ( - SnapshotConfigurationPatchRequest::default(), - serde_json::json!({}), - ), - ( - SnapshotConfigurationPatchRequest { - enabled: Some(false), - ..Default::default() - }, - serde_json::json!({"enabled": false}), - ), - ( - SnapshotConfigurationPatchRequest { - enabled: None, - gap: Some(0.0), - time_frame: Some(1440.5), - }, - serde_json::json!({"gap": 0.0, "timeFrame": 1440.5}), - ), - ( - SnapshotConfigurationPatchRequest { - enabled: Some(true), - gap: Some(30.0), - time_frame: Some(1440.0), - }, - serde_json::json!({"enabled": true, "gap": 30.0, "timeFrame": 1440.0}), - ), - ] { - let serialized = serde_json::to_value(&request).unwrap(); - assert_eq!(serialized, expected); - assert_eq!( - serde_json::from_value::(serialized).unwrap(), - request - ); - } -} - -#[test] -fn snapshot_round_trips_complete_response_and_unknown_bucket_properties() { - let value = serde_json::json!({ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "status": "done", "type": "full", "serviceId": "svc-1", - "startedAt": "2026-09-15T01:00:00Z", "finishedAt": "2026-09-15T01:00:01Z", - "sizeInBytes": 2048.0, "durationInSeconds": 1.5, "backupName": "snapshot-1", - "bucket": {"bucketProvider": "FUTURE", "newProperty": {"region": "test"}} - }); - let snapshot: Snapshot = serde_json::from_value(value.clone()).unwrap(); - assert_eq!(snapshot.status, Some(SnapshotStatus::Done)); - assert_eq!(snapshot.r#type, Some(SnapshotType::Full)); - assert_eq!(snapshot.duration_in_seconds, Some(1.5)); - assert_eq!(serde_json::to_value(snapshot).unwrap(), value); -} - -#[test] -fn snapshot_tolerates_missing_null_and_extra_fields() { - let dropped: Snapshot = serde_json::from_str("{}").unwrap(); - let nulled: Snapshot = serde_json::from_value(serde_json::json!({ - "id": null, "status": null, "type": null, "serviceId": null, - "startedAt": null, "finishedAt": null, "sizeInBytes": null, - "durationInSeconds": null, "backupName": null, "bucket": null, - "futureProperty": 1 - })) - .unwrap(); - assert_eq!(dropped, Snapshot::default()); - assert_eq!(nulled, Snapshot::default()); - assert_eq!(serde_json::to_value(nulled).unwrap(), serde_json::json!({})); -} - -#[test] -fn snapshot_enums_preserve_known_and_unknown_values() { - for (wire, expected) in [ - ("done", SnapshotStatus::Done), - ("error", SnapshotStatus::Error), - ("in_progress", SnapshotStatus::InProgress), - ("throttled", SnapshotStatus::Throttled), - ("queued", SnapshotStatus::Unknown("queued".to_string())), - ] { - let parsed: SnapshotStatus = serde_json::from_value(serde_json::json!(wire)).unwrap(); - assert_eq!(parsed, expected); - assert_eq!(parsed.to_string(), wire); - assert_eq!(serde_json::to_value(parsed).unwrap(), wire); - } - for (wire, expected) in [ - ("full", SnapshotType::Full), - ("future", SnapshotType::Unknown("future".to_string())), - ] { - let parsed: SnapshotType = serde_json::from_value(serde_json::json!(wire)).unwrap(); - assert_eq!(parsed, expected); - assert_eq!(parsed.to_string(), wire); - assert_eq!(serde_json::to_value(parsed).unwrap(), wire); - } -} - -#[test] -fn clickpipe_new_request_fields_preserve_omission_and_wire_names() { - let mut request = ClickPipePostRequest::default(); - assert!( - serde_json::to_value(&request) - .unwrap() - .get("startPaused") - .is_none() - ); - request.start_paused = Some(true); - assert_eq!(serde_json::to_value(&request).unwrap()["startPaused"], true); - - let mut table = ClickPipeDestinationTableDefinition::default(); - assert!(serde_json::to_value(&table).unwrap().get("ttl").is_none()); - table.ttl = "event_time + INTERVAL 30 DAY".to_string(); - let serialized = serde_json::to_value(&table).unwrap(); - assert_eq!(serialized["ttl"], table.ttl); - - let mut settings = ClickPipeMongoDBPipeSettings::default(); - assert!( - serde_json::to_value(&settings) - .unwrap() - .get("initialLoadParallelism") - .is_none() - ); - settings.initial_load_parallelism = Some(4); - let serialized = serde_json::to_value(&settings).unwrap(); - assert_eq!(serialized["initialLoadParallelism"], 4); - assert_eq!( - serde_json::from_value::(serialized).unwrap(), - settings - ); -} - -#[test] -fn clickpipe_new_response_fields_tolerate_missing_and_null() { - for value in [serde_json::json!({}), serde_json::json!({"ttl": null})] { - let response: ClickPipeDestinationTableDefinitionResponse = - serde_json::from_value(value).unwrap(); - assert_eq!(response.ttl, None); - assert_eq!( - serde_json::to_value(response).unwrap(), - serde_json::json!({}) - ); - } - for value in [ - serde_json::json!({}), - serde_json::json!({"initialLoadParallelism": null}), - ] { - let response: ClickPipeMongoDBPipeSettingsResponse = serde_json::from_value(value).unwrap(); - assert_eq!(response.initial_load_parallelism, None); - assert_eq!( - serde_json::to_value(response).unwrap(), - serde_json::json!({}) - ); - } - let table: ClickPipeDestinationTableDefinitionResponse = - serde_json::from_value(serde_json::json!({"ttl": "event_time + INTERVAL 30 DAY"})).unwrap(); - assert_eq!(table.ttl.as_deref(), Some("event_time + INTERVAL 30 DAY")); - let settings: ClickPipeMongoDBPipeSettingsResponse = - serde_json::from_value(serde_json::json!({"initialLoadParallelism": 4})).unwrap(); - assert_eq!(settings.initial_load_parallelism, Some(4)); -} - -#[test] -fn udf_argument_output_preserves_response_tolerance_and_old_name() { - for value in [ - serde_json::json!({}), - serde_json::json!({"name": null, "type": null}), - ] { - let argument: UdfArgumentOutput = serde_json::from_value(value).unwrap(); - let old_name: UdfArgumentResponse = argument.clone(); - assert_eq!(old_name, UdfArgumentOutput::default()); - assert_eq!( - serde_json::to_value(argument).unwrap(), - serde_json::json!({}) - ); - } - let value = serde_json::json!({"name": "x", "type": "UInt64"}); - let argument: UdfArgumentOutput = serde_json::from_value(value.clone()).unwrap(); - assert_eq!(serde_json::to_value(argument).unwrap(), value); - for invalid in [ - serde_json::json!({}), - serde_json::json!({"name": "x"}), - serde_json::json!({"type": "UInt64"}), - ] { - assert!(serde_json::from_value::(invalid).is_err()); - } -} - -/// Shared assertion for the discriminated-union `Unknown` catch-all: an -/// unrecognized payload must deserialize into the union's lossless `Unknown` -/// variant (confirmed by `is_unknown`) and re-serialize to the byte-identical -/// JSON object it came from. -fn assert_unknown_variant_round_trips(json: &str, is_unknown: impl FnOnce(&T) -> bool) -where - T: serde::Serialize + serde::de::DeserializeOwned, -{ - let original: serde_json::Value = serde_json::from_str(json).unwrap(); - let parsed: T = serde_json::from_str(json).unwrap(); - assert!( - is_unknown(&parsed), - "payload did not deserialize to the Unknown variant" - ); - let reserialized = serde_json::to_value(&parsed).unwrap(); - assert_eq!(reserialized, original); - assert!(reserialized.is_object()); -} - -/// Every `discriminated_union!` enum with a `Default` is reachable from a -/// response that drops a field carrying that union, so its default must be a -/// fixed point of its own `Deserialize`: it has to come back as the same -/// variant. It is not automatic — variants of one union can share the same -/// inline `enum` values for the discriminating field (both alert channels -/// declare `["webhook", "email"]`), so a variant defaulting its discriminator to -/// another variant's value would silently retype the value on the next -/// deserialize. The covered list is enforced structurally, not by convention: -/// it must equal the set of hand-written `impl Default for` blocks in the model -/// module tree (via the analyzer's `model_types_with_manual_default_impl`), so a -/// new union gaining a `Default` without a list entry fails this test. -#[test] -fn discriminated_union_defaults_round_trip_to_the_same_variant() { - let mut covered: Vec<&str> = Vec::new(); - - macro_rules! assert_default_round_trips { - ($($union:ty),+ $(,)?) => { - $({ - covered.push(stringify!($union)); - let default = <$union>::default(); - let json = serde_json::to_string(&default).unwrap(); - let parsed: $union = serde_json::from_str(&json).unwrap(); - assert_eq!( - parsed, - default, - "{} default deserialized as another variant from {json}", - stringify!($union), - ); - })+ - }; - } - - assert_default_round_trips!( - BackupBucket, - BackupBucketPatchRequest, - BackupBucketPostRequest, - BackupBucketProperties, - ClickPipeBigQuerySource, - ClickPipeKafkaSchemaRegistry, - ClickPipeMutateBigQuerySource, - ClickPipeMutateKafkaSchemaRegistry, - ClickPipePostPubSubSource, - ClickStackSavedFilterValue, - ClickStackSavedFilterValueResponse, - ClickStackAlertBarChartConfig, - ClickStackAlertChannel, - ClickStackAlertChartConfig, - ClickStackAlertLineChartConfig, - ClickStackAlertNumberChartConfig, - ClickStackAlertStackedLineChartConfig, - ClickStackBarChartConfig, - ClickStackCategoricalBarChartConfig, - ClickStackDashboardChartSeries, - ClickStackFilter, - ClickStackFilterInput, - ClickStackHeatmapChartConfig, - ClickStackLineChartConfig, - ClickStackNumberChartConfig, - ClickStackOnClick, - ClickStackOnClickTarget, - ClickStackPieChartConfig, - ClickStackSource, - ClickStackStackedLineChartConfig, - ClickStackTableChartConfig, - ClickStackTileConfig, - ClickStackWebhook, - ); - - covered.sort_unstable(); - let manual_default_impls = clickhouse_openapi_analyzer::model_types_with_manual_default_impl( - std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")), - ) - .unwrap(); - assert_eq!( - covered, - manual_default_impls - .iter() - .map(String::as_str) - .collect::>(), - "the covered list must equal the manual `impl Default for` blocks in the model tree" - ); -} - -#[test] -fn deserialize_organization() { - let json = r#"{ - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "My Organization", - "createdAt": "2024-01-15T10:30:00Z", - "privateEndpoints": [], - "byocConfig": [], - "enableCoreDumps": false - }"#; - let org: Organization = serde_json::from_str(json).unwrap(); - assert_eq!(org.name.as_deref(), Some("My Organization")); - assert_eq!( - org.id, - Some( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - .parse::() - .unwrap() - ) - ); - assert_eq!(org.enable_core_dumps, Some(false)); -} - -#[test] -fn serialize_organization() { - let org = Organization { - id: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".parse().unwrap()), - name: Some("Test Org".to_string()), - ..Default::default() - }; - let json = serde_json::to_value(&org).unwrap(); - assert_eq!(json["name"], "Test Org"); - assert_eq!(json["id"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); - // Absent response fields are omitted, not emitted as `null`. - assert!(json.get("createdAt").is_none()); - assert!(json.get("enableCoreDumps").is_none()); -} - -#[test] -fn deserialize_api_response_with_org_list() { - let json = r#"{ - "status": 200, - "requestId": "req-uuid-123", - "result": [ - { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "Org 1" - }, - { - "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", - "name": "Org 2" - } - ] - }"#; - let resp: ApiResponse> = serde_json::from_str(json).unwrap(); - assert_eq!(resp.status, Some(200)); - assert_eq!(resp.request_id, Some("req-uuid-123".to_string())); - let result = resp.result.unwrap(); - assert_eq!(result.len(), 2); - assert_eq!(result[0].name.as_deref(), Some("Org 1")); - assert_eq!(result[1].name.as_deref(), Some("Org 2")); -} - -#[test] -fn deserialize_api_response_error() { - let json = r#"{ - "status": 401, - "error": "Unauthorized", - "requestId": "req-uuid-456" - }"#; - let resp: ApiResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.status, Some(401)); - assert_eq!(resp.error, Some("Unauthorized".to_string())); - assert!(resp.result.is_none()); -} - -#[test] -fn deserialize_service() { - let json = r#"{ - "id": "11111111-2222-3333-4444-555555555555", - "name": "my-service", - "provider": "aws", - "region": "us-east-1", - "state": "running", - "tier": "production", - "clickhouseVersion": "24.1", - "endpoints": [ - { - "protocol": "nativesecure", - "host": "abc123.clickhouse.cloud", - "port": 9440 - } - ], - "minTotalMemoryGb": 24, - "maxTotalMemoryGb": 48, - "numReplicas": 3, - "idleScaling": true, - "idleTimeoutMinutes": 5, - "ipAccessList": [ - {"source": "0.0.0.0/0", "description": "Anywhere"} - ], - "createdAt": "2024-03-01T00:00:00Z", - "privateEndpointIds": [], - "isPrimary": true, - "isReadonly": false, - "releaseChannel": "default", - "hasTransparentDataEncryption": false, - "tags": [] - }"#; - let svc: Service = serde_json::from_str(json).unwrap(); - assert_eq!(svc.name.as_deref(), Some("my-service")); - assert_eq!(svc.provider, Some(ServiceProvider::Aws)); - assert_eq!(svc.region, Some(ServiceRegion::Us_east_1)); - assert_eq!(svc.state, Some(ServiceState::Running)); - #[cfg(feature = "deprecated-fields")] - assert_eq!(svc.tier, Some(ServiceTier::Production)); - assert_eq!(svc.num_replicas, Some(3)); - assert_eq!(svc.idle_scaling, Some(true)); - assert_eq!(svc.is_primary, Some(true)); -} - -#[test] -fn serialize_service_post_request() { - let req = ServicePostRequest { - name: "new-service".to_string(), - provider: ServicePostRequestProvider::Aws, - region: ServicePostRequestRegion::Us_east_1, - #[cfg(feature = "deprecated-fields")] - tier: Some(ServicePostRequestTier::Production), - #[cfg(feature = "deprecated-fields")] - min_total_memory_gb: Some(24.0), - #[cfg(feature = "deprecated-fields")] - max_total_memory_gb: Some(48.0), - num_replicas: Some(3), - idle_scaling: Some(true), - idle_timeout_minutes: Some(5.0), - ip_access_list: vec![IpAccessListEntry { - source: "0.0.0.0/0".to_string(), - description: Some("Anywhere".to_string()), - }], - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["name"], "new-service"); - assert_eq!(json["provider"], "aws"); - assert_eq!(json["region"], "us-east-1"); - #[cfg(feature = "deprecated-fields")] - assert_eq!(json["tier"], "production"); - #[cfg(feature = "deprecated-fields")] - assert_eq!(json["minTotalMemoryGb"], 24.0); - assert_eq!(json["ipAccessList"][0]["source"], "0.0.0.0/0"); -} - -#[test] -fn serialize_service_post_request_horizontal_autoscaling() { - let req = ServicePostRequest { - name: "horizontal-service".to_string(), - autoscaling_mode: Some(AutoscalingMode::Horizontal), - min_replicas: Some(1), - max_replicas: Some(5), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["autoscalingMode"], "horizontal"); - assert_eq!(json["minReplicas"], 1); - assert_eq!(json["maxReplicas"], 5); - - // Omitted entirely when unset — mutually exclusive with the vertical - // scaling fields, so they must not serialize as null/defaults. - let json = serde_json::to_value(ServicePostRequest::default()).unwrap(); - assert!(json.get("minReplicas").is_none()); - assert!(json.get("maxReplicas").is_none()); - assert!(json.get("autoscalingMode").is_none()); -} - -#[test] -fn deserialize_backup() { - let json = r#"{ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "status": "done", - "serviceId": "11111111-2222-3333-4444-555555555555", - "startedAt": "2024-06-01T02:00:00Z", - "finishedAt": "2024-06-01T02:05:00Z", - "sizeInBytes": 1073741824, - "durationInSeconds": 300, - "type": "full", - "backupName": "backup-2024-06-01" - }"#; - let backup: Backup = serde_json::from_str(json).unwrap(); - assert_eq!(backup.status, Some(BackupStatus::Done)); - assert_eq!(backup.r#type, Some(BackupType::Full)); - assert_eq!(backup.size_in_bytes, Some(1073741824.0)); - assert_eq!(backup.duration_in_seconds, Some(300.0)); -} - -#[test] -fn deserialize_api_key() { - let json = r#"{ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "My API Key", - "state": "enabled", - "roles": ["admin"], - "createdAt": "2024-01-01T00:00:00Z", - "expireAt": "2025-01-01T00:00:00Z" - }"#; - let key: ApiKey = serde_json::from_str(json).unwrap(); - assert_eq!(key.name.as_deref(), Some("My API Key")); - assert_eq!(key.state, Some(ApiKeyState::Enabled)); -} - -#[test] -fn deserialize_clickpipe() { - let json = r#"{ - "id": "11111111-1111-1111-1111-111111111111", - "serviceId": "22222222-2222-2222-2222-222222222222", - "name": "my-pipe", - "state": "Running", - "createdAt": "2024-06-01T00:00:00Z", - "updatedAt": "2024-06-01T01:00:00Z" - }"#; - let pipe: ClickPipe = serde_json::from_str(json).unwrap(); - assert_eq!(pipe.name.as_deref(), Some("my-pipe")); - assert_eq!(pipe.state, Some(ClickPipeState::Running)); -} - -#[test] -fn deserialize_member() { - let json = r#"{ - "userId": "user-123", - "name": "John Doe", - "email": "john@example.com", - "role": "admin", - "joinedAt": "2024-01-01T00:00:00Z" - }"#; - let member: Member = serde_json::from_str(json).unwrap(); - assert_eq!(member.name.as_deref(), Some("John Doe")); - assert_eq!(member.email.as_deref(), Some("john@example.com")); - #[cfg(feature = "deprecated-fields")] - assert_eq!(member.role, Some(MemberRole::Admin)); -} - -#[test] -fn deserialize_invitation() { - let json = r#"{ - "id": "33333333-4444-5555-6666-777777777777", - "email": "new@example.com", - "role": "developer", - "createdAt": "2024-06-01T00:00:00Z" - }"#; - let inv: Invitation = serde_json::from_str(json).unwrap(); - assert_eq!(inv.email.as_deref(), Some("new@example.com")); - #[cfg(feature = "deprecated-fields")] - assert_eq!(inv.role, Some(InvitationRole::Developer)); -} - -#[test] -fn deserialize_backup_configuration() { - let json = r#"{ - "backupPeriodInHours": 24, - "backupRetentionPeriodInHours": 168, - "backupStartTime": "02:00" - }"#; - let config: BackupConfiguration = serde_json::from_str(json).unwrap(); - assert_eq!(config.backup_period_in_hours, Some(24.0)); - assert_eq!(config.backup_retention_period_in_hours, Some(168.0)); - assert_eq!(config.backup_start_time.as_deref(), Some("02:00")); -} - -#[test] -fn roundtrip_service_state_patch_request() { - let req = ServiceStatePatchRequest { - command: Some(ServiceStatePatchRequestCommand::Start), - }; - let json = serde_json::to_string(&req).unwrap(); - let deserialized: ServiceStatePatchRequest = serde_json::from_str(&json).unwrap(); - assert_eq!( - deserialized.command, - Some(ServiceStatePatchRequestCommand::Start) - ); -} - -#[test] -fn deserialize_usage_cost() { - let json = r#"{ - "costs": [], - "grandTotalCHC": 50.25 - }"#; - let cost: UsageCost = serde_json::from_str(json).unwrap(); - assert_eq!(cost.grand_total_chc, Some(50.25)); -} - -#[test] -fn deserialize_clickpipe_settings() { - let json = r#"{ - "streaming_max_insert_wait_ms": 5000, - "object_storage_concurrency": null, - "clickhouse_max_threads": 4, - "kafka_read_committed": true - }"#; - let settings: ClickPipeSettings = serde_json::from_str(json).unwrap(); - assert_eq!(settings.streaming_max_insert_wait_ms, Some(5000)); - assert_eq!(settings.object_storage_concurrency, None); - assert_eq!(settings.clickhouse_max_threads, Some(4)); - assert_eq!(settings.kafka_read_committed, Some(true)); -} - -#[test] -fn deserialize_private_endpoint_config() { - let json = r#"{ - "endpointServiceId": "vpce-svc-123456", - "privateDnsHostname": "abc.vpce.clickhouse.cloud" - }"#; - let config: PrivateEndpointConfig = serde_json::from_str(json).unwrap(); - assert_eq!( - config.endpoint_service_id.as_deref(), - Some("vpce-svc-123456") - ); -} - -#[test] -fn absent_response_fields_are_omitted_when_serialized() { - let org = Organization { - name: Some("Test".to_string()), - ..Default::default() - }; - let json = serde_json::to_value(&org).unwrap(); - // Absent means absent: a field the API did not return is omitted from - // `--json` output rather than emitted as `null`. - assert_eq!(json, serde_json::json!({ "name": "Test" })); -} - -#[test] -fn deserialize_service_endpoint() { - let json = r#"{ - "protocol": "nativesecure", - "host": "abc123.clickhouse.cloud", - "port": 9440 - }"#; - let ep: ServiceEndpoint = serde_json::from_str(json).unwrap(); - assert_eq!(ep.protocol, Some(ServiceEndpointProtocol::Nativesecure)); - assert_eq!(ep.host.as_deref(), Some("abc123.clickhouse.cloud")); - assert_eq!(ep.port, Some(9440)); -} - -#[test] -fn api_integer_fields_round_trip_as_json_integers() { - let response: ApiResponse = serde_json::from_value(serde_json::json!({ - "status": 200, - "result": { - "numReplicas": 3, - "minReplicas": 1, - "maxReplicas": 5, - "endpoints": [{"port": 9440}] - } - })) - .unwrap(); - - let rendered = serde_json::to_string(&response).unwrap(); - assert!( - !rendered.contains(".0"), - "unexpected float JSON: {rendered}" - ); - let json = serde_json::to_value(response).unwrap(); - assert!(json["status"].is_i64()); - assert!(json["result"]["numReplicas"].is_i64()); - assert!(json["result"]["minReplicas"].is_i64()); - assert!(json["result"]["maxReplicas"].is_i64()); - assert!(json["result"]["endpoints"][0]["port"].is_i64()); - assert_eq!(json["status"], 200); - assert_eq!(json["result"]["numReplicas"], 3); - assert_eq!(json["result"]["endpoints"][0]["port"], 9440); -} - -#[test] -fn api_integer_fields_tolerate_missing_and_null_values() { - let missing: ApiResponse = serde_json::from_str("{}").unwrap(); - assert_eq!( - serde_json::to_value(missing).unwrap(), - serde_json::json!({}) - ); - let missing_endpoint: ServiceEndpoint = serde_json::from_str("{}").unwrap(); - assert_eq!(missing_endpoint.port, None); - - let nulled: ApiResponse = serde_json::from_value(serde_json::json!({ - "status": null, - "result": { - "numReplicas": null, - "minReplicas": null, - "maxReplicas": null, - "endpoints": [{"port": null}] - } - })) - .unwrap(); - assert_eq!(nulled.status, None); - let service = nulled.result.as_ref().unwrap(); - assert_eq!(service.num_replicas, None); - assert_eq!(service.min_replicas, None); - assert_eq!(service.max_replicas, None); - assert_eq!(service.endpoints.as_ref().unwrap()[0].port, None); - assert_eq!( - serde_json::to_value(nulled).unwrap(), - serde_json::json!({"result": {"endpoints": [{}]}}) - ); -} - -#[test] -fn de_facto_integer_fields_accept_integral_float_syntax() { - let response: ApiResponse = serde_json::from_value(serde_json::json!({ - "status": 200.0, - "result": {"endpoints": [{"port": 9440.0}]} - })) - .unwrap(); - assert_eq!(response.status, Some(200)); - assert_eq!( - response - .result - .as_ref() - .unwrap() - .endpoints - .as_ref() - .unwrap()[0] - .port, - Some(9440) - ); - assert_eq!( - serde_json::to_value(response).unwrap(), - serde_json::json!({"status": 200, "result": {"endpoints": [{"port": 9440}]}}) - ); - - assert!( - serde_json::from_value::>(serde_json::json!({"status": 200.5})) - .is_err() - ); - assert!( - serde_json::from_value::(serde_json::json!({"port": 9440.5})).is_err() - ); - assert!( - serde_json::from_str::>(r#"{"status":9007199254740993.0}"#).is_err() - ); -} - -#[test] -fn serialize_api_key_post_request() { - let req = ApiKeyPostRequest { - name: "test-key".to_string(), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["name"], "test-key"); -} - -#[test] -fn deserialize_clickstack_dashboard_response() { - let json = r#"{ - "id": "dash-123", - "name": "My Dashboard", - "tiles": [], - "filters": [], - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-02T00:00:00Z" - }"#; - let dash: ClickStackDashboardResponse = serde_json::from_str(json).unwrap(); - assert_eq!(dash.name.as_deref(), Some("My Dashboard")); -} - -#[test] -fn service_provider_enum_values() { - let aws: ServiceProvider = serde_json::from_str(r#""aws""#).unwrap(); - let gcp: ServiceProvider = serde_json::from_str(r#""gcp""#).unwrap(); - let azure: ServiceProvider = serde_json::from_str(r#""azure""#).unwrap(); - assert_eq!(aws, ServiceProvider::Aws); - assert_eq!(gcp, ServiceProvider::Gcp); - assert_eq!(azure, ServiceProvider::Azure); -} - -#[test] -fn service_state_enum_roundtrip() { - let states = [ - ("starting", ServiceState::Starting), - ("stopping", ServiceState::Stopping), - ("running", ServiceState::Running), - ("stopped", ServiceState::Stopped), - ("idle", ServiceState::Idle), - ]; - for (json_val, expected) in states { - let parsed: ServiceState = serde_json::from_str(&format!(r#""{json_val}""#)).unwrap(); - assert_eq!(parsed, expected); - - let serialized = serde_json::to_string(&expected).unwrap(); - assert_eq!(serialized, format!(r#""{json_val}""#)); - } -} - -#[test] -fn clickpipe_state_all_variants() { - let states = [ - "Unknown", - "Provisioning", - "Running", - "Stopping", - "Stopped", - "Failed", - "Completed", - "InternalError", - "Setup", - "Snapshot", - "Paused", - "Pausing", - "Modifying", - "Resync", - "Degraded", - ]; - for s in states { - let parsed: ClickPipeState = serde_json::from_str(&format!(r#""{s}""#)).unwrap(); - let serialized = serde_json::to_string(&parsed).unwrap(); - assert_eq!(serialized, format!(r#""{s}""#)); - } -} - -#[test] -fn postgres_table_mapping_table_engines_round_trip_and_match_values() { - // The CLI validates `--table-mapping-json`'s `tableEngine` against - // `VALUES`, so the const must stay equal to the wire values the enum - // actually accepts. - assert_eq!( - ClickPipePostgresPipeTableMappingTableengine::VALUES, - &["MergeTree", "ReplacingMergeTree", "Null"] - ); - for value in ClickPipePostgresPipeTableMappingTableengine::VALUES { - let parsed: ClickPipePostgresPipeTableMappingTableengine = - serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!( - parsed, - ClickPipePostgresPipeTableMappingTableengine::Unknown(_) - ), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - assert_eq!( - serde_json::to_string(&parsed).unwrap(), - format!(r#""{value}""#) - ); - } -} - -#[test] -fn database_table_mapping_table_engines_round_trip_and_match_values() { - macro_rules! assert_values { - ($engine:ty) => {{ - assert_eq!( - <$engine>::VALUES, - &["MergeTree", "ReplacingMergeTree", "Null"] - ); - for value in <$engine>::VALUES { - let parsed: $engine = serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert_eq!(parsed.to_string(), *value); - assert_eq!( - serde_json::to_string(&parsed).unwrap(), - format!(r#""{value}""#) - ); - } - }}; - } - - assert_values!(ClickPipeMySQLPipeTableMappingTableengine); - assert_values!(ClickPipeMongoDBPipeTableMappingTableengine); - assert_values!(ClickPipeBigQueryPipeTableMappingTableengine); -} - -#[test] -fn deserialize_activity() { - let json = r#"{ - "actorType": "api", - "actorId": "actor-123", - "createdAt": "2024-06-01T00:00:00Z" - }"#; - let activity: Activity = serde_json::from_str(json).unwrap(); - assert_eq!(activity.actor_type, Some(ActivityActortype::Api)); -} - -#[test] -fn default_response_struct_has_no_values() { - // A response model's `Default` means "nothing was returned", not a set of - // fabricated zero values. - let svc = Service::default(); - assert_eq!(svc.id, None); - assert_eq!(svc.name, None); - assert_eq!(svc.provider, None); - assert_eq!(svc.state, None); -} - -#[test] -fn deserialize_postgres_service() { - let json = r#"{ - "id": "44444444-5555-6666-7777-888888888888", - "name": "my-postgres", - "provider": "aws", - "region": "us-east-1", - "state": "running" - }"#; - let pg: PostgresService = serde_json::from_str(json).unwrap(); - assert_eq!(pg.name.as_deref(), Some("my-postgres")); -} - -#[test] -fn postgres_gcp_provider_roundtrips() { - let provider: PgProvider = serde_json::from_str(r#""gcp""#).unwrap(); - assert_eq!(provider, PgProvider::Gcp); - assert_eq!(provider.to_string(), "gcp"); - assert_eq!(serde_json::to_string(&provider).unwrap(), r#""gcp""#); - assert_eq!(PgProvider::VALUES, &["aws", "gcp"]); -} - -#[test] -fn postgres_config_enum_values_roundtrip_and_match_values() { - for value in PgConfigDefaultTransactionIsolation::VALUES { - let parsed: PgConfigDefaultTransactionIsolation = - serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!(parsed, PgConfigDefaultTransactionIsolation::Unknown(_)), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - } - for value in PgConfigSslMinProtocolVersion::VALUES { - let parsed: PgConfigSslMinProtocolVersion = - serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!(parsed, PgConfigSslMinProtocolVersion::Unknown(_)), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - } - for value in PgConfigWalCompression::VALUES { - let parsed: PgConfigWalCompression = - serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!(parsed, PgConfigWalCompression::Unknown(_)), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - } - for value in PgConfigDefaultToastCompression::VALUES { - let parsed: PgConfigDefaultToastCompression = - serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!(parsed, PgConfigDefaultToastCompression::Unknown(_)), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - } - for value in PgConfigEnableParallelHash::VALUES { - let parsed: PgConfigEnableParallelHash = - serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!(parsed, PgConfigEnableParallelHash::Unknown(_)), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - } - for value in PgConfigIoMethod::VALUES { - let parsed: PgConfigIoMethod = serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!(parsed, PgConfigIoMethod::Unknown(_)), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - } - for value in PgConfigLogLockWaits::VALUES { - let parsed: PgConfigLogLockWaits = serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!(parsed, PgConfigLogLockWaits::Unknown(_)), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - } - let future: PgConfigIoMethod = serde_json::from_str(r#""posix_aio""#).unwrap(); - assert_eq!(future, PgConfigIoMethod::Unknown("posix_aio".to_string())); - assert_eq!(future.to_string(), "posix_aio"); -} - -#[test] -fn postgres_slow_query_sort_values_roundtrip_as_known_variants() { - for value in SlowQueryPatternsGetListSortby::VALUES { - let parsed: SlowQueryPatternsGetListSortby = - serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!(parsed, SlowQueryPatternsGetListSortby::Unknown(_)), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - } - for value in SlowQueryPatternsGetListSortorder::VALUES { - let parsed: SlowQueryPatternsGetListSortorder = - serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!(parsed, SlowQueryPatternsGetListSortorder::Unknown(_)), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - } -} - -#[test] -fn postgres_gcp_sizes_roundtrip_as_known_variants() { - let sizes = [ - "c4a-highmem-lssd-4", - "c4a-highmem-lssd-8", - "c4a-highmem-lssd-16", - "c4a-highmem-lssd-32", - "c4a-highmem-lssd-48", - "c4a-highmem-lssd-64", - "c4a-highmem-lssd-72", - "c4a-standard-lssd-4", - "c4a-standard-lssd-8", - "c4a-standard-lssd-16", - "c4a-standard-lssd-32", - "c4a-standard-lssd-48", - "c4a-standard-lssd-64", - "c4a-standard-lssd-72", - "c4-highmem-lssd-4", - "c4-highmem-lssd-8", - "c4-highmem-lssd-16", - "c4-highmem-lssd-24", - "c4-highmem-lssd-32", - "c4-highmem-lssd-48", - "c4-highmem-lssd-96", - "c4-highmem-lssd-144", - "c4-highmem-lssd-192", - "c4-highmem-lssd-288", - "c4-standard-lssd-4", - "c4-standard-lssd-8", - "c4-standard-lssd-16", - "c4-standard-lssd-24", - "c4-standard-lssd-32", - "c4-standard-lssd-48", - "c4-standard-lssd-96", - "c4-standard-lssd-144", - "c4-standard-lssd-192", - "c4-standard-lssd-288", - "c4d-highmem-lssd-8", - "c4d-highmem-lssd-16", - "c4d-highmem-lssd-32", - "c4d-highmem-lssd-48", - "c4d-highmem-lssd-64", - "c4d-highmem-lssd-96", - "c4d-highmem-lssd-192", - "c4d-highmem-lssd-384", - "c4d-standard-lssd-8", - "c4d-standard-lssd-16", - "c4d-standard-lssd-32", - "c4d-standard-lssd-48", - "c4d-standard-lssd-64", - "c4d-standard-lssd-96", - "c4d-standard-lssd-192", - "c4d-standard-lssd-384", - "z3-highmem-highlssd-8", - "z3-highmem-highlssd-16", - "z3-highmem-highlssd-22", - "z3-highmem-highlssd-32", - "z3-highmem-highlssd-44", - "z3-highmem-highlssd-88", - "z3-highmem-standardlssd-14", - "z3-highmem-standardlssd-22", - "z3-highmem-standardlssd-44", - "z3-highmem-standardlssd-88", - "z3-highmem-standardlssd-176", - ]; - - for wire_value in sizes { - let size: PgSize = serde_json::from_value(wire_value.into()).unwrap(); - assert!( - !matches!(&size, PgSize::Unknown(_)), - "{wire_value} deserialized through the catch-all" - ); - assert_eq!(size.to_string(), wire_value); - assert_eq!(serde_json::to_value(&size).unwrap(), wire_value); - } -} - -#[test] -fn unknown_enum_variant_deserializes() { - // An unknown service state from the API should deserialize into Unknown(String) - let json = r#"{"state": "brand-new-state"}"#; - let svc: Service = serde_json::from_str(json).unwrap(); - assert_eq!( - svc.state, - Some(ServiceState::Unknown("brand-new-state".to_string())) - ); -} - -#[test] -fn unknown_enum_variant_roundtrips() { - let state = ServiceState::Unknown("future-state".to_string()); - let json = serde_json::to_string(&state).unwrap(); - assert_eq!(json, r#""future-state""#); - let back: ServiceState = serde_json::from_str(&json).unwrap(); - assert_eq!(back, state); -} - -#[test] -fn known_enum_variant_still_deserializes() { - let json = r#""running""#; - let state: ServiceState = serde_json::from_str(json).unwrap(); - assert_eq!(state, ServiceState::Running); -} - -#[test] -fn unknown_enum_display() { - assert_eq!(ServiceState::Running.to_string(), "running"); - assert_eq!( - ServiceState::Unknown("brand-new".to_string()).to_string(), - "brand-new" - ); -} - -// =========================================================================== -// ApiResponse envelope edge cases -// =========================================================================== - -#[test] -fn api_response_result_explicitly_null() { - let json = r#"{"status": 200, "requestId": "req-1", "result": null}"#; - let resp: ApiResponse> = serde_json::from_str(json).unwrap(); - assert_eq!(resp.status, Some(200)); - assert!(resp.result.is_none()); -} - -#[test] -fn api_response_missing_status() { - let json = r#"{"result": []}"#; - let resp: ApiResponse> = serde_json::from_str(json).unwrap(); - assert!(resp.status.is_none()); - assert!(resp.request_id.is_none()); - assert_eq!(resp.result.unwrap().len(), 0); -} - -#[test] -fn api_response_extra_fields_ignored() { - let json = r#"{ - "status": 200, - "requestId": "req-1", - "result": {"name": "Test"}, - "extraField": true, - "anotherField": 42, - "nestedExtra": {"a": 1} - }"#; - let resp: ApiResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.status, Some(200)); - let org = resp.result.unwrap(); - assert_eq!(org.name.as_deref(), Some("Test")); -} - -#[test] -fn api_response_empty_object() { - let json = r#"{}"#; - let resp: ApiResponse = serde_json::from_str(json).unwrap(); - assert!(resp.status.is_none()); - assert!(resp.request_id.is_none()); - assert!(resp.result.is_none()); - assert!(resp.error.is_none()); -} - -// =========================================================================== -// Request body serialization (camelCase, None omission, enum variants) -// =========================================================================== - -#[test] -fn serialize_service_patch_request() { - let req = ServicePatchRequest { - name: Some("renamed".to_string()), - release_channel: Some(ServicePatchRequestReleasechannel::Default), - enable_core_dumps: Some(false), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["name"], "renamed"); - assert_eq!(json["releaseChannel"], "default"); - assert_eq!(json["enableCoreDumps"], false); - // None fields must be omitted - assert!(json.get("ipAccessList").is_none()); - assert!(json.get("privateEndpointIds").is_none()); - assert!(json.get("endpoints").is_none()); - assert!(json.get("tags").is_none()); -} - -#[test] -fn serialize_service_state_patch_request_start() { - let req = ServiceStatePatchRequest { - command: Some(ServiceStatePatchRequestCommand::Start), - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["command"], "start"); -} - -#[test] -fn serialize_service_state_patch_request_stop() { - let req = ServiceStatePatchRequest { - command: Some(ServiceStatePatchRequestCommand::Stop), - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["command"], "stop"); -} - -#[test] -fn serialize_service_replica_scaling_patch_request() { - let req = ServiceReplicaScalingPatchRequest { - num_replicas: Some(5), - min_replicas: None, - max_replicas: None, - min_replica_memory_gb: Some(16.0), - max_replica_memory_gb: Some(64.0), - idle_scaling: Some(true), - idle_timeout_minutes: Some(10.0), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["numReplicas"], 5); - assert_eq!(json["minReplicaMemoryGb"], 16.0); - assert_eq!(json["maxReplicaMemoryGb"], 64.0); - assert_eq!(json["idleScaling"], true); - assert_eq!(json["idleTimeoutMinutes"], 10.0); -} - -#[test] -fn serialize_service_scaling_patch_request() { - let req = ServiceScalingPatchRequest { - num_replicas: Some(3), - #[cfg(feature = "deprecated-fields")] - min_total_memory_gb: Some(24.0), - #[cfg(feature = "deprecated-fields")] - max_total_memory_gb: Some(48.0), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["numReplicas"], 3); - #[cfg(feature = "deprecated-fields")] - assert_eq!(json["minTotalMemoryGb"], 24.0); - #[cfg(feature = "deprecated-fields")] - assert_eq!(json["maxTotalMemoryGb"], 48.0); - assert!(json.get("idleScaling").is_none()); -} - -#[test] -fn serialize_service_password_patch_request_default() { - let req = ServicePasswordPatchRequest::default(); - let json = serde_json::to_value(&req).unwrap(); - // All fields should be omitted, leaving just {} - assert_eq!(json, serde_json::json!({})); -} - -#[test] -fn serialize_clickpipe_state_patch_request() { - let req = ClickPipeStatePatchRequest { - command: Some(ClickPipeStatePatchRequestCommand::Start), - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["command"], "start"); - - let stop = ClickPipeStatePatchRequest { - command: Some(ClickPipeStatePatchRequestCommand::Stop), - }; - let json = serde_json::to_value(&stop).unwrap(); - assert_eq!(json["command"], "stop"); -} - -#[test] -fn serialize_clickpipes_cdc_scaling_patch_request() { - let req = ClickPipesCdcScalingPatchRequest { - replica_cpu_millicores: Some(4000), - replica_memory_gb: Some(16.0), - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["replicaCpuMillicores"], 4000); - assert_eq!(json["replicaMemoryGb"], 16.0); -} - -#[test] -fn serialize_backup_configuration_patch_request() { - let req = 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 json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["backupPeriodInHours"], 12.0); - assert_eq!(json["backupRetentionPeriodInHours"], 336.0); - assert_eq!(json["backupStartTime"], "03:00"); -} - -/// The three states of `backupStartTime` are what let a caller both set and -/// unset the start time: only an explicit `null` clears the stored value, and -/// omitting the key must leave it untouched. -#[test] -fn serialize_backup_configuration_patch_request_omits_an_untouched_start_time() { - let req = BackupConfigurationPatchRequest { - backup_period_in_hours: Some(12.0), - backup_retention_period_in_hours: None, - backup_start_time: None, - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json, serde_json::json!({ "backupPeriodInHours": 12.0 })); -} - -#[test] -fn serialize_backup_configuration_patch_request_clears_the_start_time_with_null() { - let req = BackupConfigurationPatchRequest { - backup_period_in_hours: Some(12.0), - backup_retention_period_in_hours: None, - backup_start_time: Some(None), - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!( - json, - serde_json::json!({ "backupPeriodInHours": 12.0, "backupStartTime": null }) - ); -} - -#[test] -fn serialize_postgres_service_post_request() { - let req = PostgresServicePostRequest { - name: "pg-new".to_string(), - provider: PgProvider::Aws, - region: "us-east-1".to_string(), - size: PgSize::C6gd_large, - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["name"], "pg-new"); - assert_eq!(json["provider"], "aws"); - assert_eq!(json["region"], "us-east-1"); - assert_eq!(json["size"], "c6gd.large"); - assert!(json.get("storageSize").is_none()); - // Optional fields omitted - assert!(json.get("haType").is_none()); - assert!(json.get("pgConfig").is_none()); - assert!(json.get("pgBouncerConfig").is_none()); -} - -#[test] -fn serialize_postgres_service_set_state() { - let req = PostgresServiceSetState { - command: PostgresServiceSetStateCommand::Restart, - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["command"], "restart"); -} - -#[test] -fn serialize_postgres_service_set_password() { - let req = PostgresServiceSetPassword { - password: "s3cur3".to_string(), - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["password"], "s3cur3"); -} - -#[test] -fn serialize_postgres_read_replica_request() { - let req = PostgresServiceReadReplicaRequest { - name: "pg-replica".to_string(), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["name"], "pg-replica"); - assert!(json.get("pgConfig").is_none()); - assert!(json.get("pgBouncerConfig").is_none()); -} - -#[test] -fn serialize_byoc_infrastructure_post_request() { - let req = ByocInfrastructurePostRequest { - account_id: "123456789012".to_string(), - availability_zone_suffixes: Some(vec![ - ByocAvailabilityZoneSuffix::A, - ByocAvailabilityZoneSuffix::B, - ByocAvailabilityZoneSuffix::C, - ByocAvailabilityZoneSuffix::D, - ByocAvailabilityZoneSuffix::E, - ByocAvailabilityZoneSuffix::F, - ]), - display_name: Some("My BYOC".to_string()), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["accountId"], "123456789012"); - assert_eq!( - json["availabilityZoneSuffixes"], - serde_json::json!(["a", "b", "c", "d", "e", "f"]) - ); - assert_eq!(json["displayName"], "My BYOC"); -} - -#[test] -fn byoc_infrastructure_post_request_requires_only_account_and_region() { - // Compile-time strictness: accountId and regionId are the spec's only required fields. - let req = ByocInfrastructurePostRequest { - account_id: "123456789012".to_string(), - region_id: ByocInfrastructurePostRequestRegionid::Eastus, - availability_zone_suffixes: None, - display_name: None, - external_id: None, - gcp_pod_cidr_range_names: None, - gcp_shared_vpc_host_project_id: None, - private_subnet_ids: None, - public_subnet_ids: None, - service_principal_client_id: None, - tags: None, - tenant_id: None, - vpc_cidr_range: None, - vpc_id: None, - }; - assert_eq!( - serde_json::to_value(&req).unwrap(), - serde_json::json!({"accountId": "123456789012", "regionId": "eastus"}) - ); -} - -#[test] -fn byoc_infrastructure_post_request_serializes_every_optional_field() { - let req = ByocInfrastructurePostRequest { - account_id: "project-1".to_string(), - region_id: ByocInfrastructurePostRequestRegionid::Us_central1, - availability_zone_suffixes: Some(vec![ByocAvailabilityZoneSuffix::A]), - display_name: Some("gcp".to_string()), - external_id: Some("ext".to_string()), - gcp_pod_cidr_range_names: Some(vec!["pods".to_string()]), - gcp_shared_vpc_host_project_id: Some("host".to_string()), - private_subnet_ids: Some(vec!["private".to_string()]), - public_subnet_ids: Some(vec!["public".to_string()]), - service_principal_client_id: Some("sp".to_string()), - tags: Some(ByocInfrastructureTags::from([( - "env".to_string(), - "prod".to_string(), - )])), - tenant_id: Some("tenant".to_string()), - vpc_cidr_range: Some("10.0.0.0/16".to_string()), - vpc_id: Some("network".to_string()), - }; - assert_eq!( - serde_json::to_value(&req).unwrap(), - serde_json::json!({ - "accountId": "project-1", - "regionId": "us-central1", - "availabilityZoneSuffixes": ["a"], - "displayName": "gcp", - "externalId": "ext", - "gcpPodCidrRangeNames": ["pods"], - "gcpSharedVpcHostProjectId": "host", - "privateSubnetIds": ["private"], - "publicSubnetIds": ["public"], - "servicePrincipalClientId": "sp", - "tags": {"env": "prod"}, - "tenantId": "tenant", - "vpcCidrRange": "10.0.0.0/16", - "vpcId": "network" - }) - ); -} - -#[test] -fn byoc_infrastructure_validate_post_request_is_strict_on_account_and_region() { - let req = ByocInfrastructureValidatePostRequest { - account_id: "sub-1".to_string(), - region_id: ByocInfrastructureValidatePostRequestRegionid::Westus3, - availability_zone_suffixes: None, - external_id: None, - gcp_pod_cidr_range_names: None, - gcp_shared_vpc_host_project_id: None, - private_subnet_ids: None, - public_subnet_ids: None, - service_principal_client_id: Some("sp".to_string()), - tags: None, - tenant_id: Some("tenant".to_string()), - vpc_cidr_range: None, - vpc_id: None, - }; - assert_eq!( - serde_json::to_value(&req).unwrap(), - serde_json::json!({ - "accountId": "sub-1", - "regionId": "westus3", - "servicePrincipalClientId": "sp", - "tenantId": "tenant" - }) - ); -} - -#[test] -fn byoc_infrastructure_tags_preserve_dynamic_keys() { - let tags: ByocInfrastructureTags = - serde_json::from_str(r#"{"team":"data","kubernetes.io/cluster":"owned","":"empty"}"#) - .unwrap(); - assert_eq!(tags.len(), 3); - assert_eq!(tags["team"], "data"); - assert_eq!(tags["kubernetes.io/cluster"], "owned"); - assert_eq!(tags[""], "empty"); - assert_eq!( - serde_json::to_value(&tags).unwrap(), - serde_json::json!({"team": "data", "kubernetes.io/cluster": "owned", "": "empty"}) - ); -} - -#[test] -fn byoc_infrastructure_details_missing_and_null_fields_are_none() { - let missing: ByocInfrastructureDetails = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, ByocInfrastructureDetails::default()); - assert_eq!( - serde_json::to_value(&missing).unwrap(), - serde_json::json!({}) - ); - - let null: ByocInfrastructureDetails = serde_json::from_value(serde_json::json!({ - "id": null, "state": null, "accountId": null, "regionId": null, - "cloudProvider": null, "displayName": null, "enablePrivateLink": null, - "enablePrivateLoadBalancer": null, "enablePublicLoadBalancer": null, - "vpcCidrRange": null, "vpcAvailabilityZoneList": null, "isByoVpc": null, - "byoVpcId": null, "byoVpcPrivateSubnetIds": null, "byoVpcPodCidrRangeNames": null, - "byoVpcSharedVpcHostProjectId": null, "gcpPscSubnetId": null - })) - .unwrap(); - assert_eq!(null, ByocInfrastructureDetails::default()); -} - -#[test] -fn byoc_infrastructure_details_enums_preserve_unknown_values() { - let details: ByocInfrastructureDetails = serde_json::from_value(serde_json::json!({ - "state": "infra-hibernating", - "regionId": "mars-north-1", - "cloudProvider": "oci" - })) - .unwrap(); - assert_eq!( - details.state, - Some(ByocInfrastructureDetailsState::Unknown( - "infra-hibernating".to_string() - )) - ); - assert_eq!( - details.region_id, - Some(ByocInfrastructureDetailsRegionid::Unknown( - "mars-north-1".to_string() - )) - ); - assert_eq!( - details.cloud_provider, - Some(ByocInfrastructureDetailsCloudprovider::Unknown( - "oci".to_string() - )) - ); -} - -#[test] -fn byoc_infrastructure_progress_missing_and_null_fields_are_none() { - let missing: ByocInfrastructureProgress = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, ByocInfrastructureProgress::default()); - assert_eq!( - serde_json::to_value(&missing).unwrap(), - serde_json::json!({}) - ); - - let null: ByocInfrastructureProgress = serde_json::from_value(serde_json::json!({ - "id": null, "status": null, "updatedAt": null, "stages": null - })) - .unwrap(); - assert_eq!(null, ByocInfrastructureProgress::default()); -} - -#[test] -fn byoc_infrastructure_progress_stage_missing_and_null_fields_are_none() { - let missing: ByocInfrastructureProgressStage = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, ByocInfrastructureProgressStage::default()); - assert_eq!( - serde_json::to_value(&missing).unwrap(), - serde_json::json!({}) - ); - - let null: ByocInfrastructureProgressStage = serde_json::from_value(serde_json::json!({ - "name": null, "status": null, "updatedAt": null, "message": null, "subStages": null - })) - .unwrap(); - assert_eq!(null, ByocInfrastructureProgressStage::default()); -} - -#[test] -fn byoc_infrastructure_progress_stages_nest_recursively() { - let progress: ByocInfrastructureProgress = serde_json::from_value(serde_json::json!({ - "id": "byoc-1", - "status": "waiting", - "stages": [{ - "name": "network", - "status": "in_progress", - "subStages": [{ - "name": "vpc", - "status": "ready", - "updatedAt": "2026-09-30T10:00:00Z", - "subStages": [{ - "name": "subnet-a", - "status": "not_started", - "message": "queued" - }] - }] - }] - })) - .unwrap(); - assert_eq!( - progress.status, - Some(ByocInfrastructureProgressStatus::Waiting) - ); - let network = &progress.stages.as_ref().unwrap()[0]; - assert_eq!( - network.status, - Some(ByocInfrastructureProgressStageStatus::InProgress) - ); - let vpc = &network.sub_stages.as_ref().unwrap()[0]; - assert_eq!(vpc.name.as_deref(), Some("vpc")); - assert!(vpc.updated_at.is_some()); - let leaf = &vpc.sub_stages.as_ref().unwrap()[0]; - assert_eq!(leaf.name.as_deref(), Some("subnet-a")); - assert_eq!( - leaf.status, - Some(ByocInfrastructureProgressStageStatus::NotStarted) - ); - assert_eq!(leaf.message.as_deref(), Some("queued")); - assert_eq!(leaf.sub_stages, None); - - let reserialized = serde_json::to_value(&progress).unwrap(); - assert_eq!( - reserialized["stages"][0]["subStages"][0]["subStages"][0]["status"], - "not_started" - ); - assert!( - reserialized["stages"][0]["subStages"][0]["subStages"][0] - .get("subStages") - .is_none() - ); -} - -#[test] -fn byoc_infrastructure_progress_status_preserves_unknown_values() { - let status: ByocInfrastructureProgressStatus = - serde_json::from_str(r#""rolling_back""#).unwrap(); - assert_eq!( - status, - ByocInfrastructureProgressStatus::Unknown("rolling_back".to_string()) - ); - assert_eq!(status.to_string(), "rolling_back"); - assert_eq!( - ByocInfrastructureProgressStageStatus::NotStarted.to_string(), - "not_started" - ); -} - -#[test] -fn byoc_infrastructure_validation_missing_and_null_fields_are_none() { - let missing: ByocInfrastructureValidation = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, ByocInfrastructureValidation::default()); - assert_eq!( - serde_json::to_value(&missing).unwrap(), - serde_json::json!({}) - ); - - let null: ByocInfrastructureValidation = serde_json::from_value(serde_json::json!({ - "cloudProvider": null, "allPassed": null, "anyPassed": null, - "supported": null, "checks": null - })) - .unwrap(); - assert_eq!(null, ByocInfrastructureValidation::default()); -} - -#[test] -fn byoc_infrastructure_validation_check_missing_and_null_fields_are_none() { - let missing: ByocInfrastructureValidationCheck = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, ByocInfrastructureValidationCheck::default()); - assert_eq!( - serde_json::to_value(&missing).unwrap(), - serde_json::json!({}) - ); - - let null: ByocInfrastructureValidationCheck = serde_json::from_value(serde_json::json!({ - "name": null, "action": null, "allowed": null, "reason": null, "group": null - })) - .unwrap(); - assert_eq!(null, ByocInfrastructureValidationCheck::default()); -} - -#[test] -fn byoc_availability_zone_suffix_preserves_unknown_values() { - let suffix: ByocAvailabilityZoneSuffix = serde_json::from_str(r#""future""#).unwrap(); - assert_eq!( - suffix, - ByocAvailabilityZoneSuffix::Unknown("future".to_string()) - ); - assert_eq!(serde_json::to_string(&suffix).unwrap(), r#""future""#); -} - -#[test] -fn serialize_byoc_infrastructure_patch_request() { - let req = ByocInfrastructurePatchRequest { - display_name: Some("Renamed".to_string()), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json, serde_json::json!({"displayName": "Renamed"})); -} - -#[test] -fn serialize_byoc_infrastructure_patch_request_full() { - let req = ByocInfrastructurePatchRequest { - display_name: Some("Renamed".to_string()), - enable_private_link: Some(true), - enable_private_load_balancer: Some(true), - enable_public_load_balancer: Some(false), - gcp_psc_subnet_id: Some("psc-subnet".to_string()), - tags: Some(ByocInfrastructureTags::from([( - "team".to_string(), - "data".to_string(), - )])), - }; - assert_eq!( - serde_json::to_value(&req).unwrap(), - serde_json::json!({ - "displayName": "Renamed", - "enablePrivateLink": true, - "enablePrivateLoadBalancer": true, - "enablePublicLoadBalancer": false, - "gcpPscSubnetId": "psc-subnet", - "tags": {"team": "data"} - }) - ); - assert_eq!( - serde_json::to_value(ByocInfrastructurePatchRequest::default()).unwrap(), - serde_json::json!({}) - ); -} - -#[test] -fn byoc_infrastructure_private_endpoint_config_missing_and_null_fields_are_none() { - let missing: ByocInfrastructurePrivateEndpointConfig = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, ByocInfrastructurePrivateEndpointConfig::default()); - assert_eq!( - serde_json::to_value(&missing).unwrap(), - serde_json::json!({}) - ); - - let null: ByocInfrastructurePrivateEndpointConfig = serde_json::from_value(serde_json::json!({ - "endpointName": null, "privateDnsHostname": null - })) - .unwrap(); - assert_eq!(null, ByocInfrastructurePrivateEndpointConfig::default()); -} - -#[test] -fn byoc_infrastructure_tags_response_missing_and_null_fields_are_none() { - let missing: ByocInfrastructureTagsResponse = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, ByocInfrastructureTagsResponse::default()); - assert_eq!( - serde_json::to_value(&missing).unwrap(), - serde_json::json!({}) - ); - - let null: ByocInfrastructureTagsResponse = - serde_json::from_value(serde_json::json!({"tags": null})).unwrap(); - assert_eq!(null, ByocInfrastructureTagsResponse::default()); - - let empty: ByocInfrastructureTagsResponse = - serde_json::from_value(serde_json::json!({"tags": {}})).unwrap(); - assert_eq!(empty.tags, Some(ByocInfrastructureTags::new())); -} - -#[test] -fn serialize_invitation_post_request() { - let req = InvitationPostRequest { - email: "alice@example.com".to_string(), - #[cfg(feature = "deprecated-fields")] - role: Some(InvitationPostRequestRole::Developer), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["email"], "alice@example.com"); - #[cfg(feature = "deprecated-fields")] - assert_eq!(json["role"], "developer"); - // By default the deprecated `role` field is gated out and never serialized. - #[cfg(not(feature = "deprecated-fields"))] - assert!(json.get("role").is_none()); -} - -#[cfg(feature = "deprecated-fields")] -#[test] -fn serialize_member_patch_request() { - let req = MemberPatchRequest { - role: Some(MemberPatchRequestRole::Admin), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["role"], "admin"); -} - -/// In the default build the deprecated request fields don't exist on the -/// struct, so callers can't set them and they never reach the wire. -#[cfg(not(feature = "deprecated-fields"))] -#[test] -fn deprecated_request_fields_absent_by_default() { - let member = MemberPatchRequest { - assigned_role_ids: Some(vec!["admin".to_string()]), - }; - assert!(serde_json::to_value(&member).unwrap().get("role").is_none()); - - let invitation = InvitationPostRequest { - email: "alice@example.com".to_string(), - assigned_role_ids: Some(vec!["admin".to_string()]), - }; - assert!( - serde_json::to_value(&invitation) - .unwrap() - .get("role") - .is_none() - ); - - let scaling = ServiceScalingPatchRequest { - num_replicas: Some(3), - ..Default::default() - }; - let scaling = serde_json::to_value(&scaling).unwrap(); - assert!(scaling.get("minTotalMemoryGb").is_none()); - assert!(scaling.get("maxTotalMemoryGb").is_none()); -} - -#[test] -fn serialize_clickpipe_patch_request() { - let req = ClickPipePatchRequest { - name: Some("renamed-pipe".to_string()), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["name"], "renamed-pipe"); - assert!(json.get("source").is_none()); - assert!(json.get("destination").is_none()); -} - -#[test] -fn serialize_create_reverse_private_endpoint() { - let req = CreateReversePrivateEndpoint { - description: "Test RPE".to_string(), - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["description"], "Test RPE"); -} - -#[test] -fn serialize_instance_query_endpoint_post_request() { - let req = InstanceServiceQueryApiEndpointsPostRequest { - allowed_origins: Some("https://example.com".to_string()), - roles: vec![QueryEndpointRole::SqlConsoleReadOnly], - ..Default::default() - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["allowedOrigins"], "https://example.com"); - assert_eq!(json["roles"], serde_json::json!(["sql_console_read_only"])); -} - -#[test] -fn deserialize_query_endpoint_roles_tolerates_unknown_values() { - let endpoint: ServiceQueryAPIEndpoint = serde_json::from_value(serde_json::json!({ - "roles": ["sql_console_admin", "future_role"] - })) - .unwrap(); - - assert_eq!( - endpoint.roles, - Some(vec![ - QueryEndpointRole::SqlConsoleAdmin, - QueryEndpointRole::Unknown("future_role".to_string()), - ]) - ); -} - -#[test] -fn slow_query_sort_enums_preserve_unknown_values() { - let sort_by: SlowQueryPatternsGetListSortby = - serde_json::from_str(r#""future_metric""#).unwrap(); - let sort_order: SlowQueryPatternsGetListSortorder = - serde_json::from_str(r#""future_order""#).unwrap(); - - assert_eq!(sort_by.to_string(), "future_metric"); - assert_eq!(sort_order.to_string(), "future_order"); -} - -#[test] -fn serialize_servic_private_endpointe_post_request() { - let req = ServicPrivateEndpointePostRequest { - id: "vpce-abc".to_string(), - description: Some("My PE".to_string()), - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["id"], "vpce-abc"); - assert_eq!(json["description"], "My PE"); -} - -#[test] -fn serialize_postgres_instance_config() { - let config = PostgresInstanceConfig { - pg_config: PgConfig { - max_connections: Some(serde_json::json!(200)), - autovacuum_max_workers: Some(serde_json::json!(5)), - ..Default::default() - }, - pg_bouncer_config: PgBouncerConfig::default(), - }; - let json = serde_json::to_value(&config).unwrap(); - assert_eq!(json["pgConfig"]["max_connections"], 200); - assert_eq!(json["pgConfig"]["autovacuum_max_workers"], 5); - assert!(json.get("pgBouncerConfig").is_some()); -} - -#[test] -fn serialize_postgres_instance_config_always_includes_both_nested() { - // The live API rejects PATCH/POST bodies that omit either `pgConfig` or - // `pgBouncerConfig` with `BAD_REQUEST: ... 'undefined'`, so the envelope - // always serializes both — defaulting to `{}` — while inner pgConfig - // fields stay opt-in. See #163 for the matrix evidence. - let config = PostgresInstanceConfig { - pg_config: PgConfig { - max_connections: Some(serde_json::json!(200)), - ..Default::default() - }, - pg_bouncer_config: PgBouncerConfig::default(), - }; - let json = serde_json::to_value(&config).unwrap(); - assert!( - json.get("pgBouncerConfig").is_some(), - "pgBouncerConfig must always be present" - ); - assert_eq!(json["pgBouncerConfig"], serde_json::json!({})); - assert_eq!(json["pgConfig"]["max_connections"], 200); - let pg = json["pgConfig"].as_object().unwrap(); - assert_eq!( - pg.len(), - 1, - "PgConfig should only serialize the one set field, got {pg:?}" - ); -} - -#[test] -fn serialize_clickpipe_object_storage_ingestion_controls() { - let source = ClickPipePostObjectStorageSource { - url: "https://bucket.s3.amazonaws.com/events/*.json".to_string(), - skip_initial_load: Some(true), - start_after: Some("events/2026-06-01/".to_string()), - ..Default::default() - }; - let json = serde_json::to_value(&source).unwrap(); - assert_eq!(json["skipInitialLoad"], true); - assert_eq!(json["startAfter"], "events/2026-06-01/"); - - // Omitted from the wire when unset. - let json = serde_json::to_value(ClickPipePostObjectStorageSource::default()).unwrap(); - assert!(json.get("skipInitialLoad").is_none()); - assert!(json.get("startAfter").is_none()); -} - -#[test] -fn serialize_clickpipe_mutate_postgres_source_omits_absent_credentials() { - // IAM_ROLE authentication has no username or password: `iamRole` is the - // whole credential, so the `credentials` object must not reach the wire. - let iam_role = ClickPipeMutatePostgresSource { - authentication: ClickPipeMutatePostgresSourceAuthentication::IAM_ROLE, - credentials: None, - iam_role: Some("arn:aws:iam::123456789012:role/clickpipe".to_string()), - host: "postgres.example".to_string(), - port: 5432, - database: "source-db".to_string(), - ..Default::default() - }; - let json = serde_json::to_value(&iam_role).unwrap(); - assert!( - json.get("credentials").is_none(), - "credentials must be omitted, got {json}" - ); - assert_eq!(json["iamRole"], "arn:aws:iam::123456789012:role/clickpipe"); - assert_eq!(json["authentication"], "IAM_ROLE"); - - // Basic auth still sends the pair verbatim. - let basic = ClickPipeMutatePostgresSource { - authentication: ClickPipeMutatePostgresSourceAuthentication::Basic, - credentials: Some(PLAIN { - username: "user".to_string(), - password: "secret".to_string(), - }), - ..iam_role.clone() - }; - let json = serde_json::to_value(&basic).unwrap(); - assert_eq!(json["credentials"]["username"], "user"); - assert_eq!(json["credentials"]["password"], "secret"); -} - -#[test] -fn deserialize_scaling_schedule_entry_fixed_scaling_fields() { - let json = r#"{ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "name": "weekday-peak", - "weekdays": [1, 2, 3, 4, 5], - "startHourUtc": 8, - "endHourUtc": 18, - "isActiveNow": false, - "autoscalingMode": "vertical", - "minReplicaMemoryGb": 16, - "maxReplicaMemoryGb": 32 - }"#; - let entry: ScalingScheduleEntry = serde_json::from_str(json).unwrap(); - assert_eq!(entry.autoscaling_mode, Some(AutoscalingMode::Vertical)); - assert_eq!(entry.min_replica_memory_gb, Some(16.0)); - assert_eq!(entry.max_replica_memory_gb, Some(32.0)); - - // Writing a fetched entry back goes through the explicit conversion, which - // resolves the fields the request requires. - let req = ScalingScheduleEntryRequest::try_from(entry).unwrap(); - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["autoscalingMode"], "vertical"); - assert_eq!(json["minReplicaMemoryGb"], 16.0); - assert_eq!(json["maxReplicaMemoryGb"], 32.0); -} - -#[test] -fn serialize_postgres_instance_config_default_envelope() { - // Default envelope serializes to the minimal accepted body shape. - let json = serde_json::to_value(PostgresInstanceConfig::default()).unwrap(); - assert_eq!( - json, - serde_json::json!({ "pgConfig": {}, "pgBouncerConfig": {} }) - ); -} - -// =========================================================================== -// Forward compatibility: extra unknown fields ignored -// =========================================================================== - -#[test] -fn organization_ignores_extra_fields() { - let json = r#"{"name":"Test","brandNewField":"surprise","anotherNew":42}"#; - let org: Organization = serde_json::from_str(json).unwrap(); - assert_eq!(org.name.as_deref(), Some("Test")); -} - -#[test] -fn service_ignores_extra_fields() { - let json = r#"{"name":"svc","state":"running","futureField":"v2","nested":{"a":1}}"#; - let svc: Service = serde_json::from_str(json).unwrap(); - assert_eq!(svc.name.as_deref(), Some("svc")); - assert_eq!(svc.state, Some(ServiceState::Running)); -} - -#[test] -fn clickpipe_ignores_extra_fields() { - let json = r#"{"name":"pipe","state":"Running","newFeatureFlag":true}"#; - let pipe: ClickPipe = serde_json::from_str(json).unwrap(); - assert_eq!(pipe.name.as_deref(), Some("pipe")); - assert_eq!(pipe.state, Some(ClickPipeState::Running)); -} - -#[test] -fn backup_ignores_extra_fields() { - let json = r#"{"status":"done","type":"full","compressionRatio":0.85}"#; - let backup: Backup = serde_json::from_str(json).unwrap(); - assert_eq!(backup.status, Some(BackupStatus::Done)); -} - -#[test] -fn api_key_ignores_extra_fields() { - let json = r#"{"name":"key","state":"enabled","rotationPolicy":"weekly"}"#; - let key: ApiKey = serde_json::from_str(json).unwrap(); - assert_eq!(key.name.as_deref(), Some("key")); - assert_eq!(key.state, Some(ApiKeyState::Enabled)); -} - -#[test] -fn member_ignores_extra_fields() { - let json = r#"{"name":"Alice","role":"admin","department":"eng","mfa":true}"#; - let m: Member = serde_json::from_str(json).unwrap(); - assert_eq!(m.name.as_deref(), Some("Alice")); - #[cfg(feature = "deprecated-fields")] - assert_eq!(m.role, Some(MemberRole::Admin)); -} - -#[test] -fn invitation_ignores_extra_fields() { - let json = r#"{"email":"a@b.com","role":"developer","expiresIn":"7d"}"#; - let inv: Invitation = serde_json::from_str(json).unwrap(); - assert_eq!(inv.email.as_deref(), Some("a@b.com")); -} - -#[test] -fn postgres_service_ignores_extra_fields() { - let json = r#"{"name":"pg","state":"running","maintenanceWindow":"sun-02:00"}"#; - let pg: PostgresService = serde_json::from_str(json).unwrap(); - assert_eq!(pg.name.as_deref(), Some("pg")); -} - -#[test] -fn activity_ignores_extra_fields() { - let json = r#"{"actorType":"user","sourceIp":"1.2.3.4"}"#; - let a: Activity = serde_json::from_str(json).unwrap(); - assert_eq!(a.actor_type, Some(ActivityActortype::User)); -} - -#[test] -fn backup_configuration_ignores_extra_fields() { - let json = r#"{"backupPeriodInHours":24,"backupRetentionPeriodInHours":168,"compressionEnabled":true}"#; - let c: BackupConfiguration = serde_json::from_str(json).unwrap(); - assert_eq!(c.backup_period_in_hours, Some(24.0)); -} - -// =========================================================================== -// Minimal/partial response deserialization -// =========================================================================== - -#[test] -fn service_minimal_response() { - let json = r#"{"id":"11111111-2222-3333-4444-555555555555"}"#; - let svc: Service = serde_json::from_str(json).unwrap(); - assert_eq!( - svc.id, - Some( - "11111111-2222-3333-4444-555555555555" - .parse::() - .unwrap() - ) - ); - // Every response field is `Option`: an omitted key is `None`, not a - // fabricated zero value. - assert_eq!(svc.name, None); - assert_eq!(svc.provider, None); - assert_eq!(svc.state, None); - assert_eq!(svc.endpoints, None); -} - -#[cfg(feature = "deprecated-fields")] -#[test] -fn service_deserializes_deprecated_fields() { - // With the `deprecated-fields` feature on, deprecated fields exist on the - // struct and deserialize normally. Without the feature they are absent from - // the struct entirely (see `deprecated_fields_absent_by_default`). - let json = r#"{"tier":"production","minTotalMemoryGb":24,"maxTotalMemoryGb":48}"#; - let svc: Service = serde_json::from_str(json).unwrap(); - assert_eq!(svc.min_total_memory_gb, Some(24.0)); - assert_eq!(svc.max_total_memory_gb, Some(48.0)); -} - -/// In the default build (no `deprecated-fields` feature) deprecated response -/// fields don't exist on the struct, so they can't be read and never appear in -/// serialized output. Deserializing a payload that contains them simply ignores -/// the extra keys. -#[cfg(not(feature = "deprecated-fields"))] -#[test] -fn deprecated_fields_absent_by_default() { - let svc: Service = serde_json::from_str( - r#"{"name":"svc","tier":"production","minTotalMemoryGb":24,"maxTotalMemoryGb":48}"#, - ) - .unwrap(); - let v = serde_json::to_value(&svc).unwrap(); - assert!(v.get("tier").is_none()); - assert!(v.get("minTotalMemoryGb").is_none()); - assert!(v.get("maxTotalMemoryGb").is_none()); - - let m: Member = serde_json::from_str(r#"{"name":"Alice","role":"admin"}"#).unwrap(); - assert!(serde_json::to_value(&m).unwrap().get("role").is_none()); -} - -#[test] -#[cfg(not(feature = "deprecated-fields"))] -fn service_hides_deprecated_fields_when_serializing() { - let svc: Service = serde_json::from_str( - r#"{"name":"svc","tier":"production","minTotalMemoryGb":24,"maxTotalMemoryGb":48}"#, - ) - .unwrap(); - let value = serde_json::to_value(&svc).unwrap(); - let obj = value.as_object().unwrap(); - // Deprecated fields are omitted from serialized output by default. - assert!(!obj.contains_key("tier"), "tier should be hidden"); - assert!(!obj.contains_key("minTotalMemoryGb")); - assert!(!obj.contains_key("maxTotalMemoryGb")); - // Non-deprecated fields are still present. - assert_eq!(obj.get("name").and_then(|v| v.as_str()), Some("svc")); -} - -#[test] -#[cfg(feature = "deprecated-fields")] -fn service_shows_deprecated_fields_with_feature() { - let svc: Service = serde_json::from_str( - r#"{"tier":"production","minTotalMemoryGb":24,"maxTotalMemoryGb":48}"#, - ) - .unwrap(); - let value = serde_json::to_value(&svc).unwrap(); - let obj = value.as_object().unwrap(); - assert!(obj.contains_key("tier")); - assert!(obj.contains_key("minTotalMemoryGb")); - assert!(obj.contains_key("maxTotalMemoryGb")); -} - -#[test] -fn service_empty_object() { - let svc: Service = serde_json::from_str("{}").unwrap(); - assert_eq!(svc, Service::default()); - assert_eq!(svc.id, None); - assert_eq!(svc.name, None); -} - -#[test] -fn organization_minimal_response() { - let org: Organization = serde_json::from_str(r#"{"name":"X"}"#).unwrap(); - assert_eq!(org.name.as_deref(), Some("X")); - assert_eq!(org.id, None); - assert_eq!(org.created_at, None); -} - -#[test] -fn clickpipe_minimal_response() { - let pipe: ClickPipe = serde_json::from_str("{}").unwrap(); - assert_eq!(pipe, ClickPipe::default()); - assert_eq!(pipe.id, None); - assert_eq!(pipe.name, None); - assert_eq!(pipe.state, None); -} - -#[test] -fn schema_discovery_response_tolerates_dropped_and_null_meta() { - let dropped: ClickPipeSchemaDiscoveryResponse = serde_json::from_str("{}").unwrap(); - let nulled: ClickPipeSchemaDiscoveryResponse = - serde_json::from_str(r#"{"fields":null,"meta":null}"#).unwrap(); - - assert_eq!(dropped, ClickPipeSchemaDiscoveryResponse::default()); - assert_eq!(nulled, ClickPipeSchemaDiscoveryResponse::default()); - assert_eq!(serde_json::to_value(nulled).unwrap(), serde_json::json!({})); -} - -#[test] -fn udf_responses_tolerate_dropped_and_null_fields() { - let dropped: Udf = serde_json::from_str("{}").unwrap(); - let nulled: Udf = serde_json::from_str( - r#"{"functionName":null,"runtime":null,"arguments":null,"createdAt":null,"memoryLimitMib":null}"#, - ) - .unwrap(); - - assert_eq!(dropped, Udf::default()); - assert_eq!(nulled, Udf::default()); - assert_eq!(serde_json::to_value(nulled).unwrap(), serde_json::json!({})); -} - -#[test] -fn udf_response_models_tolerate_absent_and_null_fields() { - // These models are reached only from Client response types. A dropped key - // and an explicit null must therefore both deserialize to None, and None - // must be omitted again when rendered as JSON. - macro_rules! assert_tolerant_response { - ($response:ty, $null_payload:literal) => {{ - let dropped: $response = serde_json::from_str("{}").unwrap(); - let nulled: $response = serde_json::from_str($null_payload).unwrap(); - assert_eq!(dropped, <$response>::default()); - assert_eq!(nulled, <$response>::default()); - assert_eq!(serde_json::to_value(nulled).unwrap(), serde_json::json!({})); - }}; - } - - assert_tolerant_response!( - Pagination, - r#"{"totalRecords":null,"currentCursor":null,"nextCursor":null,"limit":null}"# - ); - assert_tolerant_response!( - UdfAttachment, - r#"{"functionName":null,"serviceId":null,"status":null,"version":null}"# - ); - assert_tolerant_response!( - UdfAttachmentListResponse, - r#"{"items":null,"pagination":null}"# - ); - assert_tolerant_response!(UdfListResponse, r#"{"items":null,"pagination":null}"#); - assert_tolerant_response!( - UdfUploadSession, - r#"{"uploadId":null,"uploadUrl":null,"expiresAt":null}"# - ); - assert_tolerant_response!( - UdfVersionListResponse, - r#"{"items":null,"pagination":null}"# - ); -} - -#[test] -fn scim_user_enterprise_extension_uses_the_exact_wire_name() { - let user = ScimUser { - enterprise_user: Some(ScimEnterpriseUser::default()), - ..Default::default() - }; - let json = serde_json::to_value(&user).unwrap(); - let extension = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"; - assert!(json.get(extension).is_some()); - assert_eq!( - serde_json::from_value::(json) - .unwrap() - .enterprise_user, - user.enterprise_user - ); -} - -#[test] -fn udf_create_request_variants_keep_required_fields_strict() { - // The discriminator is intentionally lossless at the union boundary. Once - // it selects a known request shape, serde rejects a body missing a required - // field rather than inventing a default value. - assert!( - serde_json::from_str::( - r#"{"arguments":[],"functionName":"my_udf","returnType":"String","runtime":"native","type":"executable"}"#, - ) - .is_err() - ); - assert!( - serde_json::from_str::( - r#"{"arguments":[],"functionName":"my_udf","returnType":"String","runtime":"native","type":"executable_pool"}"#, - ) - .is_err() - ); - - let request = UdfCreateRequestV1 { - arguments: vec![UdfArgument { - name: "x".to_string(), - r#type: "UInt64".to_string(), - }], - function_name: "my_udf".to_string(), - return_type: "UInt64".to_string(), - runtime: UdfRuntime::Native, - r#type: UdfCreateRequestV1Type::Executable, - upload_id: "upload-1".to_string(), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(request).unwrap(), - serde_json::json!({ - "arguments": [{"name": "x", "type": "UInt64"}], - "functionName": "my_udf", - "returnType": "UInt64", - "runtime": "native", - "type": "executable", - "uploadId": "upload-1", - }) - ); -} - -#[test] -fn postgres_service_minimal_response() { - let pg: PostgresService = - serde_json::from_str(r#"{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}"#).unwrap(); - // Every response field is `Option`: an omitted key is `None`, not a - // fabricated zero value. - assert_eq!(pg.name, None); - assert_eq!(pg.state, None); -} - -#[test] -fn postgres_service_response_tolerates_dropped_and_null_fields() { - // A response field the API stops sending, and one it sends as an explicit - // `null`, must both land as `None` rather than failing the response. `null` - // is the case `#[serde(default)]` never covered: it only fills a missing - // key. - let dropped: PostgresService = serde_json::from_str("{}").unwrap(); - let nulled: PostgresService = serde_json::from_str( - r#"{"id":null,"name":null,"state":null,"tags":null,"storageSize":null,"createdAt":null}"#, - ) - .unwrap(); - assert_eq!(dropped, PostgresService::default()); - assert_eq!(nulled, PostgresService::default()); - assert_eq!(nulled.tags, None); - assert_eq!(nulled.storage_size, None); -} - -#[test] -fn postgres_service_response_omits_absent_fields_when_serialized() { - // Absent means absent: a response field that was not returned is omitted - // from `--json` output rather than emitted as `null`. - let pg = PostgresService { - name: Some("pg-1".to_string()), - ..Default::default() - }; - let json = serde_json::to_value(&pg).unwrap(); - assert_eq!(json, serde_json::json!({ "name": "pg-1" })); -} - -#[test] -fn postgres_instance_config_response_converts_back_into_a_request_body() { - let response = PostgresInstanceConfigResponse { - pg_config: Some(PgConfigResponse { - max_connections: Some(serde_json::json!(200)), - ..Default::default() - }), - pg_bouncer_config: Some(PgBouncerConfigResponse::from([ - ("default_pool_size".to_string(), "16".to_string()), - ("future_parameter".to_string(), "on".to_string()), - ])), - }; - let request = PostgresInstanceConfig::try_from(response).unwrap(); - assert_eq!( - request.pg_config.max_connections, - Some(serde_json::json!(200)) - ); - assert_eq!( - serde_json::to_value(&request).unwrap(), - serde_json::json!({ "pgConfig": { "max_connections": 200 }, "pgBouncerConfig": {"default_pool_size": "16", "future_parameter": "on"} }) - ); -} - -#[test] -fn postgres_instance_config_response_conversion_reports_missing_required_fields() { - // Both nested objects are required in a write body, so the write-back - // conversion must fail loudly instead of inventing empty objects. - let missing_bouncer = PostgresInstanceConfig::try_from(PostgresInstanceConfigResponse { - pg_config: Some(PgConfigResponse::default()), - pg_bouncer_config: None, - }) - .unwrap_err(); - assert_eq!(missing_bouncer.fields(), ["pgBouncerConfig"]); - - let missing_both = - PostgresInstanceConfig::try_from(PostgresInstanceConfigResponse::default()).unwrap_err(); - assert_eq!(missing_both.fields(), ["pgBouncerConfig", "pgConfig"]); - assert_eq!( - missing_both.to_string(), - "the API response is missing required field(s): pgBouncerConfig, pgConfig" - ); -} - -#[test] -fn backup_minimal_response() { - let b: Backup = serde_json::from_str("{}").unwrap(); - assert_eq!(b.id, None); - assert_eq!(b.status, None); - assert_eq!(b.size_in_bytes, None); -} - -#[test] -fn api_key_minimal_response() { - let k: ApiKey = serde_json::from_str(r#"{"name":"k"}"#).unwrap(); - assert_eq!(k.name.as_deref(), Some("k")); - assert_eq!(k.id, None); - assert_eq!(k.state, None); -} - -#[test] -fn service_response_tolerates_dropped_and_null_fields() { - // A response field the API stops sending, and one it sends as an explicit - // `null`, must both land as `None` rather than failing the response. `null` - // is the case `#[serde(default)]` never covered: it only fills a missing - // key. - let dropped: Service = serde_json::from_str("{}").unwrap(); - let nulled: Service = serde_json::from_str( - r#"{"id":null,"name":null,"state":null,"endpoints":null,"ipAccessList":null, - "tags":null,"currentScaling":null,"scalingSchedule":null,"numReplicas":null}"#, - ) - .unwrap(); - assert_eq!(dropped, Service::default()); - assert_eq!(nulled, Service::default()); - assert_eq!(nulled.endpoints, None); - assert_eq!(nulled.ip_access_list, None); - assert_eq!(nulled.tags, None); -} - -#[test] -fn service_response_omits_absent_fields_when_serialized() { - // Absent means absent, at every level of the response tree: a field that - // was not returned is omitted from `--json` output, never emitted as - // `null`. - let svc = Service { - name: Some("svc".to_string()), - ip_access_list: Some(vec![IpAccessListEntryResponse { - source: Some("0.0.0.0/0".to_string()), - description: None, - }]), - tags: Some(vec![ResourceTagsV1Response { - key: Some("env".to_string()), - value: None, - }]), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&svc).unwrap(), - serde_json::json!({ - "name": "svc", - "ipAccessList": [{ "source": "0.0.0.0/0" }], - "tags": [{ "key": "env" }], - }) - ); -} - -#[test] -fn shared_leaves_stay_strict_on_the_request_side() { - // `ipAccessListEntry` and `resourceTagsV1` are sent as well as returned, so - // each splits: the request variant keeps the schema's required fields as - // `T`, and only the response variant is all-`Option`. - let entry = IpAccessListEntry { - source: "0.0.0.0/0".to_string(), - description: None, - }; - assert_eq!( - serde_json::to_value(&entry).unwrap(), - serde_json::json!({ "source": "0.0.0.0/0" }) - ); - // A request payload missing a required field is rejected, not defaulted. - assert!(serde_json::from_str::("{}").is_err()); - assert!(serde_json::from_str::("{}").is_err()); - // The response variants accept the same payload. - assert_eq!( - serde_json::from_str::("{}").unwrap(), - IpAccessListEntryResponse::default() - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ResourceTagsV1Response::default() - ); -} - -#[test] -fn infrastructure_responses_tolerate_dropped_and_null_fields() { - // Reverse private endpoints and quotas are response-only, so every field is - // `Option`: a dropped key and an explicit `null` both land as `None`. - // `null` is the case `#[serde(default)]` never covered. - let dropped: ReversePrivateEndpoint = serde_json::from_str("{}").unwrap(); - let nulled: ReversePrivateEndpoint = serde_json::from_str( - r#"{"id":null,"description":null,"status":null,"type":null,"dnsNames":null, - "privateDnsNames":null,"endpointId":null,"serviceId":null, - "customPrivateDnsMappings":null,"dnsTargets":null, - "privateDnsMappings":null}"#, - ) - .unwrap(); - assert_eq!(dropped, ReversePrivateEndpoint::default()); - assert_eq!(nulled, ReversePrivateEndpoint::default()); - // A `null` on a list field is the residual case the previous - // `#[serde(default)]` policy still failed on. - assert_eq!(nulled.dns_names, None); - assert_eq!(nulled.custom_private_dns_mappings, None); - assert_eq!(nulled.dns_targets, None); - assert_eq!(nulled.private_dns_mappings, None); - - let quota: OrganizationQuota = serde_json::from_str(r#"{"name":"Services"}"#).unwrap(); - assert_eq!(quota.name.as_deref(), Some("Services")); - assert_eq!(quota.quota_code, None); - assert_eq!(quota.value, None); - assert_eq!(quota.adjustable, None); -} - -#[test] -fn reverse_private_endpoint_dns_targets_and_mappings_tolerate_dropped_and_null_fields() { - // Both nested schemas are response-only, so every field is `Option`. - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ReversePrivateEndpointDnsTarget::default() - ); - assert_eq!( - serde_json::from_str::( - r#"{"id":null,"kind":null,"internalDnsName":null}"# - ) - .unwrap(), - ReversePrivateEndpointDnsTarget::default() - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ReversePrivateEndpointPrivateDnsMapping::default() - ); - assert_eq!( - serde_json::from_str::( - r#"{"privateDnsName":null,"internalDnsName":null}"# - ) - .unwrap(), - ReversePrivateEndpointPrivateDnsMapping::default() - ); - assert_eq!( - serde_json::from_str::( - r#"{"privateDnsName":null,"targetId":null}"# - ) - .unwrap(), - CustomPrivateDnsMappingResponse::default() - ); - - let rpe: ReversePrivateEndpoint = serde_json::from_str( - r#"{ - "customPrivateDnsMappings":[{"privateDnsName":"db.example.com","targetId":"rcfg-1"}], - "dnsTargets":[{"id":"rcfg-1","kind":"RESOURCE_CONFIGURATION","internalDnsName":"a.on.aws"}, - {"id":"rcfg-2","kind":"FUTURE_KIND"}], - "privateDnsMappings":[{"privateDnsName":"my.example.com","internalDnsName":"b.on.aws"}] - }"#, - ) - .unwrap(); - assert_eq!( - rpe.custom_private_dns_mappings.unwrap()[0] - .target_id - .as_deref(), - Some("rcfg-1") - ); - assert_eq!( - rpe.dns_targets, - Some(vec![ - ReversePrivateEndpointDnsTarget { - id: Some("rcfg-1".to_string()), - internal_dns_name: Some("a.on.aws".to_string()), - kind: Some(ReversePrivateEndpointDnsTargetKind::RESOURCE_CONFIGURATION), - }, - ReversePrivateEndpointDnsTarget { - id: Some("rcfg-2".to_string()), - internal_dns_name: None, - kind: Some(ReversePrivateEndpointDnsTargetKind::Unknown( - "FUTURE_KIND".to_string() - )), - }, - ]) - ); - assert_eq!( - rpe.private_dns_mappings, - Some(vec![ReversePrivateEndpointPrivateDnsMapping { - private_dns_name: Some("my.example.com".to_string()), - internal_dns_name: Some("b.on.aws".to_string()), - }]) - ); -} - -#[test] -fn reverse_private_endpoint_omits_absent_fields_when_serialized() { - // Absence stays absent in `--json` output, including inside the nested - // response variant of the shared `customPrivateDnsMapping` schema. - let rpe = ReversePrivateEndpoint { - description: Some("MSK endpoint".to_string()), - custom_private_dns_mappings: Some(vec![CustomPrivateDnsMappingResponse { - private_dns_name: None, - target_id: None, - }]), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&rpe).unwrap(), - serde_json::json!({ - "description": "MSK endpoint", - "customPrivateDnsMappings": [{}], - }) - ); -} - -#[test] -fn infrastructure_shared_leaves_stay_strict_on_the_request_side() { - // `customPrivateDnsMapping` and `RBACPolicyTags` are sent as well as - // returned, so each splits: only the `{Name}Response` variant is used by a - // response type, and neither variant fabricates a value for a missing key. - let mapping = CustomPrivateDnsMapping { - private_dns_name: Some("db.internal".to_string()), - target_id: None, - }; - assert_eq!( - serde_json::to_value(&mapping).unwrap(), - serde_json::json!({ "privateDnsName": "db.internal" }) - ); - let targeted = CustomPrivateDnsMapping { - private_dns_name: Some("db.internal".to_string()), - target_id: Some("rcfg-097648d8068504966".to_string()), - }; - assert_eq!( - serde_json::to_value(&targeted).unwrap(), - serde_json::json!({ - "privateDnsName": "db.internal", - "targetId": "rcfg-097648d8068504966", - }) - ); - // Both schemas are all-optional upstream, so the request variants accept an - // empty object too — what the split guarantees is that the response variant - // can never regain a required field. - assert_eq!( - serde_json::from_str::("{}").unwrap(), - CustomPrivateDnsMappingResponse::default() - ); - assert_eq!( - serde_json::from_str::(r#"{"grants":null,"roleV2":null}"#).unwrap(), - RBACPolicyTagsResponse::default() - ); - let policy: RBACPolicy = serde_json::from_str(r#"{"tags":{"grants":["select"]}}"#).unwrap(); - assert_eq!( - policy.tags, - Some(RBACPolicyTagsResponse { - grants: Some(vec!["select".to_string()]), - role_v2: None, - }) - ); -} - -#[test] -fn reverse_private_endpoint_request_rejects_a_missing_required_field() { - // The request side is strict: without `#[serde(default)]` a payload missing - // `description` or `type` is rejected rather than silently defaulted. - assert!(serde_json::from_str::("{}").is_err()); - let body = CreateReversePrivateEndpoint { - description: "New RPE".to_string(), - r#type: CreateReversePrivateEndpointType::MSK_MULTI_VPC, - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&body).unwrap(), - serde_json::json!({ "description": "New RPE", "type": "MSK_MULTI_VPC" }) - ); -} - -#[test] -fn resource_tag_response_converts_back_into_a_request_tag() { - let tag = ResourceTagsV1::try_from(ResourceTagsV1Response { - key: Some("env".to_string()), - value: Some("dev".to_string()), - }) - .unwrap(); - assert_eq!(tag.key, "env"); - assert_eq!(tag.value.as_deref(), Some("dev")); - - // A tag is identified by its key, so a keyless response tag cannot be - // written back. - let missing = ResourceTagsV1::try_from(ResourceTagsV1Response { - key: None, - value: Some("dev".to_string()), - }) - .unwrap_err(); - assert_eq!(missing.fields(), ["key"]); -} - -#[test] -fn scaling_schedule_entry_response_converts_back_into_a_request_entry() { - let entry = ScalingScheduleEntry { - name: Some("weekday-peak".to_string()), - weekdays: Some(vec![1, 2, 3]), - start_hour_utc: Some(8), - end_hour_utc: Some(18), - autoscaling_mode: Some(AutoscalingMode::Vertical), - min_replica_memory_gb: Some(16.0), - ..Default::default() - }; - let request = ScalingScheduleEntryRequest::try_from(entry).unwrap(); - assert_eq!(request.name, "weekday-peak"); - assert_eq!(request.weekdays, vec![1, 2, 3]); - assert_eq!(request.start_hour_utc, 8); - assert_eq!(request.end_hour_utc, 18); - assert_eq!(request.min_replica_memory_gb, Some(16.0)); - - // An upsert replaces the whole schedule, so an entry the API returned - // without its window bounds, weekdays or name cannot be re-sent. - let missing = - ScalingScheduleEntryRequest::try_from(ScalingScheduleEntry::default()).unwrap_err(); - assert_eq!( - missing.fields(), - ["endHourUtc", "name", "startHourUtc", "weekdays"] - ); -} - -#[test] -fn upgrade_window_response_converts_back_into_a_put_body() { - let request = UpgradeWindowPutRequest::try_from(UpgradeWindow { - // `duration` is response-only and does not cross over. - duration: Some(UpgradeWindowDuration::SixHours), - start_hour_utc: Some(UpgradeWindowStartHourUtc::Hour6), - weekday: Some(2), - }) - .unwrap(); - assert_eq!(request.start_hour_utc, UpgradeWindowStartHourUtc::Hour6); - assert_eq!(request.weekday, 2); - - let missing = UpgradeWindowPutRequest::try_from(UpgradeWindow::default()).unwrap_err(); - assert_eq!(missing.fields(), ["startHourUtc", "weekday"]); -} - -#[test] -fn clickstack_dashboard_minimal_response() { - let d: ClickStackDashboardResponse = serde_json::from_str("{}").unwrap(); - assert_eq!(d.id, None); - assert_eq!(d.name, None); -} - -// =========================================================================== -// Extended model deserialization (complex/nested types) -// =========================================================================== - -#[test] -fn deserialize_aws_backup_bucket() { - let json = r#"{ - "bucketPath": "s3://my-bucket/prefix", - "bucketProvider": "AWS", - "iamRoleArn": "arn:aws:iam::123:role/backup", - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - }"#; - let b: AwsBackupBucket = serde_json::from_str(json).unwrap(); - assert_eq!(b.bucket_path.as_deref(), Some("s3://my-bucket/prefix")); - assert_eq!( - b.iam_role_arn.as_deref(), - Some("arn:aws:iam::123:role/backup") - ); -} - -#[test] -fn deserialize_backup_bucket_dispatches_aws() { - let json = r#"{ - "bucketPath": "s3://my-bucket/prefix", - "bucketProvider": "AWS", - "iamRoleArn": "arn:aws:iam::123:role/backup", - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - }"#; - let b: BackupBucket = serde_json::from_str(json).unwrap(); - assert!(matches!(b, BackupBucket::AwsBackupBucket(_))); - if let BackupBucket::AwsBackupBucket(aws) = b { - assert_eq!(aws.bucket_path.as_deref(), Some("s3://my-bucket/prefix")); - assert_eq!( - aws.iam_role_arn.as_deref(), - Some("arn:aws:iam::123:role/backup") - ); - } -} - -#[test] -fn deserialize_backup_bucket_dispatches_gcp() { - let json = r#"{ - "accessKeyId": "GOOG1234567890", - "bucketPath": "gs://my-gcp-bucket/prefix", - "bucketProvider": "GCP", - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - }"#; - let b: BackupBucket = serde_json::from_str(json).unwrap(); - assert!(matches!(b, BackupBucket::GcpBackupBucket(_))); - if let BackupBucket::GcpBackupBucket(gcp) = b { - assert_eq!(gcp.access_key_id.as_deref(), Some("GOOG1234567890")); - assert_eq!( - gcp.bucket_path.as_deref(), - Some("gs://my-gcp-bucket/prefix") - ); - } -} - -#[test] -fn deserialize_backup_bucket_dispatches_azure() { - let json = r#"{ - "bucketProvider": "AZURE", - "containerName": "my-container", - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - }"#; - let b: BackupBucket = serde_json::from_str(json).unwrap(); - assert!(matches!(b, BackupBucket::AzureBackupBucket(_))); - if let BackupBucket::AzureBackupBucket(azure) = b { - assert_eq!(azure.container_name.as_deref(), Some("my-container")); - } -} - -#[test] -fn deserialize_backup_bucket_unknown_provider() { - // The Unknown payload round-trips losslessly as the original object, not a - // JSON string. - let json = r#"{ - "bucketProvider": "NEW_PROVIDER", - "somefield": "somevalue", - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - }"#; - assert_unknown_variant_round_trips(json, |b: &BackupBucket| { - matches!(b, BackupBucket::Unknown(_)) - }); -} - -#[test] -fn backup_bucket_patch_request_unknown_provider_round_trips() { - let json = r#"{ - "bucketProvider": "NEW_PROVIDER", - "somefield": "somevalue" - }"#; - assert_unknown_variant_round_trips(json, |b: &BackupBucketPatchRequest| { - matches!(b, BackupBucketPatchRequest::Unknown(_)) - }); -} - -#[test] -fn backup_bucket_post_request_unknown_provider_round_trips() { - let json = r#"{ - "bucketProvider": "NEW_PROVIDER", - "somefield": "somevalue" - }"#; - assert_unknown_variant_round_trips(json, |b: &BackupBucketPostRequest| { - matches!(b, BackupBucketPostRequest::Unknown(_)) - }); -} - -#[test] -fn backup_bucket_properties_unknown_provider_round_trips() { - let json = r#"{ - "bucketProvider": "NEW_PROVIDER", - "somefield": "somevalue" - }"#; - assert_unknown_variant_round_trips(json, |b: &BackupBucketProperties| { - matches!(b, BackupBucketProperties::Unknown(_)) - }); -} - -#[test] -fn backup_bucket_unknown_display_emits_compact_json() { - let json = r#"{"bucketProvider":"NEW_PROVIDER","somefield":"somevalue"}"#; - let b: BackupBucket = serde_json::from_str(json).unwrap(); - let value: serde_json::Value = serde_json::from_str(json).unwrap(); - // Display emits the raw compact-JSON payload carried by the Unknown variant. - assert_eq!(b.to_string(), value.to_string()); -} - -#[test] -fn deserialize_service_post_response() { - let json = r#"{ - "service": { - "id": "11111111-2222-3333-4444-555555555555", - "name": "new-svc", - "state": "provisioning" - }, - "password": "gen-pw-123" - }"#; - let resp: ServicePostResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.password.as_deref(), Some("gen-pw-123")); - let service = resp.service.unwrap(); - assert_eq!(service.name.as_deref(), Some("new-svc")); - assert_eq!(service.state, Some(ServiceState::Provisioning)); -} - -#[test] -fn deserialize_usage_cost_with_records() { - let json = r#"{ - "costs": [ - { - "name": "compute", - "totalCHC": 25.5 - }, - { - "name": "storage", - "totalCHC": 10.0 - } - ], - "grandTotalCHC": 35.5 - }"#; - let cost: UsageCost = serde_json::from_str(json).unwrap(); - assert_eq!(cost.grand_total_chc, Some(35.5)); - assert_eq!(cost.costs.as_deref().map(<[_]>::len), Some(2)); -} - -#[test] -fn deserialize_postgres_instance_config() { - let json = r#"{ - "pgConfig": { - "max_connections": 200, - "shared_buffers": "256MB" - }, - "pgBouncerConfig": {} - }"#; - let config: PostgresInstanceConfigResponse = serde_json::from_str(json).unwrap(); - assert_eq!( - config.pg_config.unwrap().max_connections, - Some(serde_json::json!(200)) - ); -} - -#[test] -fn deserialize_postgres_instance_config_string_wrapped_numbers() { - // The live GET endpoint returns numeric pgConfig values wrapped in JSON - // strings (e.g. "max_connections": "100"). The spec types these fields - // as string-or-number, so they are modelled as serde_json::Value and - // both representations must deserialize. - let json = r#"{ - "pgConfig": { - "max_connections": "100", - "random_page_cost": "1.1", - "max_worker_processes": 8, - "autovacuum_naptime": "5s", - "autovacuum_vacuum_scale_factor": "0.2", - "autovacuum_max_workers": 3 - }, - "pgBouncerConfig": {} - }"#; - let config: PostgresInstanceConfigResponse = serde_json::from_str(json).unwrap(); - let pg_config = config.pg_config.expect("pgConfig present in the payload"); - assert_eq!(pg_config.max_connections, Some(serde_json::json!("100"))); - assert_eq!(pg_config.random_page_cost, Some(serde_json::json!("1.1"))); - assert_eq!(pg_config.max_worker_processes, Some(serde_json::json!(8))); - assert_eq!(pg_config.autovacuum_naptime, Some(serde_json::json!("5s"))); - assert_eq!( - pg_config.autovacuum_vacuum_scale_factor, - Some(serde_json::json!("0.2")) - ); - assert_eq!(pg_config.autovacuum_max_workers, Some(serde_json::json!(3))); -} - -#[test] -fn deserialize_reverse_private_endpoint() { - let json = r#"{ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "description": "MSK endpoint", - "status": "available" - }"#; - let rpe: ReversePrivateEndpoint = serde_json::from_str(json).unwrap(); - assert_eq!(rpe.description.as_deref(), Some("MSK endpoint")); - assert_eq!( - rpe.status, - Some(ReversePrivateEndpointStatus::Other("available".to_string())) - ); -} - -#[test] -fn deserialize_clickpipe_kafka_source() { - let json = r#"{ - "brokers": "broker1:9092,broker2:9092", - "topics": "my-topic", - "groupId": "my-group", - "securityProtocol": "SASL_SSL" - }"#; - let src: ClickPipeKafkaSource = serde_json::from_str(json).unwrap(); - assert_eq!(src.brokers.as_deref(), Some("broker1:9092,broker2:9092")); - assert_eq!(src.topics.as_deref(), Some("my-topic")); -} - -#[test] -fn deserialize_clickpipe_destination() { - let json = r#"{ - "database": "default", - "table": "events", - "managedTable": true, - "columns": [ - {"name": "id", "type": "UInt64"}, - {"name": "ts", "type": "DateTime"} - ] - }"#; - let dest: ClickPipeDestination = serde_json::from_str(json).unwrap(); - assert_eq!(dest.database.as_deref(), Some("default")); - assert_eq!(dest.table.as_deref(), Some("events")); - let columns = dest.columns.expect("columns should populate"); - assert_eq!(columns.len(), 2); - assert_eq!(columns[0].name.as_deref(), Some("id")); -} - -#[test] -fn deserialize_clickpipe_scaling() { - let json = r#"{ - "replicas": 3, - "concurrency": 2 - }"#; - let s: ClickPipeScalingResponse = serde_json::from_str(json).unwrap(); - assert_eq!(s.replicas, Some(3)); - #[cfg(feature = "deprecated-fields")] - assert_eq!(s.concurrency, Some(2)); -} - -// =========================================================================== -// Upgrade window (issue #203 drift) -// =========================================================================== - -#[test] -fn deserialize_upgrade_window() { - let json = r#"{ - "weekday": 2, - "startHourUtc": 6, - "duration": 6 - }"#; - let w: UpgradeWindow = serde_json::from_str(json).unwrap(); - assert_eq!(w.weekday, Some(2)); - assert_eq!(w.start_hour_utc, Some(UpgradeWindowStartHourUtc::Hour6)); - assert_eq!(w.duration, Some(UpgradeWindowDuration::SixHours)); - - let round_tripped = serde_json::to_value(&w).unwrap(); - assert_eq!(round_tripped["startHourUtc"], 6); - assert_eq!(round_tripped["weekday"], 2); - assert_eq!(round_tripped["duration"], 6); -} - -#[test] -fn upgrade_window_numeric_enums_round_trip_every_known_value() { - for (wire, value) in [ - (0, UpgradeWindowStartHourUtc::Hour0), - (6, UpgradeWindowStartHourUtc::Hour6), - (12, UpgradeWindowStartHourUtc::Hour12), - (18, UpgradeWindowStartHourUtc::Hour18), - ] { - assert_eq!(serde_json::to_value(value).unwrap(), wire); - assert_eq!( - serde_json::from_value::(wire.into()).unwrap(), - value - ); - } - - assert_eq!( - serde_json::to_value(UpgradeWindowDuration::SixHours).unwrap(), - 6 - ); - assert_eq!( - serde_json::from_value::(serde_json::json!(6)).unwrap(), - UpgradeWindowDuration::SixHours - ); -} - -#[test] -fn deserialize_upgrade_window_tolerates_unknown_numeric_values() { - let window: UpgradeWindow = serde_json::from_value(serde_json::json!({ - "startHourUtc": 3, - "duration": 21600 - })) - .unwrap(); - - assert_eq!( - window.start_hour_utc, - Some(UpgradeWindowStartHourUtc::Unknown(3)) - ); - assert_eq!(window.duration, Some(UpgradeWindowDuration::Unknown(21600))); - assert_eq!( - serde_json::to_value(window).unwrap(), - serde_json::json!({"startHourUtc": 3, "duration": 21600}) - ); -} - -#[test] -fn serialize_upgrade_window_put_request() { - let req = UpgradeWindowPutRequest { - weekday: 5, - start_hour_utc: UpgradeWindowStartHourUtc::Hour18, - }; - let v = serde_json::to_value(&req).unwrap(); - assert_eq!(v["weekday"], 5); - assert_eq!(v["startHourUtc"], 18); - assert!(v.get("start_hour_utc").is_none()); -} - -// =========================================================================== -// ClickPipe Pub/Sub source (issue #203 drift) -// =========================================================================== - -#[test] -fn deserialize_clickpipe_pubsub_source() { - let json = r#"{ - "topic": "projects/p/topics/t", - "projectId": "my-project", - "authentication": "SERVICE_ACCOUNT", - "format": "JSONEachRow", - "seekType": "latest", - "ackDeadline": 60, - "enableOrdering": true, - "filter": "attribute.foo = \"bar\"" - }"#; - let src: ClickPipePubSubSource = serde_json::from_str(json).unwrap(); - assert_eq!(src.topic.as_deref(), Some("projects/p/topics/t")); - assert_eq!(src.project_id.as_deref(), Some("my-project")); - assert_eq!( - src.authentication, - Some(ClickPipePubSubSourceAuthentication::ServiceAccount) - ); - assert_eq!(src.format, Some(ClickPipePubSubSourceFormat::JSONEachRow)); - assert_eq!(src.seek_type, Some(ClickPipePubSubSourceSeektype::Latest)); - assert_eq!(src.ack_deadline, Some(60)); - assert_eq!(src.enable_ordering, Some(true)); -} - -#[test] -fn deserialize_clickpipe_post_pubsub_source_required_fields() { - let json = r#"{ - "topic": "projects/p/topics/t", - "projectId": "my-project", - "authentication": "SERVICE_ACCOUNT", - "format": "JSONEachRow", - "seekType": "earliest", - "serviceAccountKey": { - "serviceAccountFile": "/path/to/key.json" - } - }"#; - let src: ClickPipePostPubSubSource = serde_json::from_str(json).unwrap(); - let ClickPipePostPubSubSource::ClickPipePostPubSubServiceAccountSource(src) = src else { - panic!("expected service-account source") - }; - assert_eq!(src.topic, "projects/p/topics/t"); - assert_eq!(src.seek_type, ClickPipePostPubSubSourceSeektype::Earliest); - assert_eq!( - src.service_account_key.service_account_file, - "/path/to/key.json" - ); -} - -#[test] -fn serialize_clickpipe_patch_pubsub_source_omits_absent_credentials() { - let empty = serde_json::to_value(ClickPipePatchPubSubSource::default()).unwrap(); - assert_eq!(empty, serde_json::json!({})); - - let ack_deadline_only = serde_json::to_value(ClickPipePatchPubSubSource { - ack_deadline: Some(30), - ..Default::default() - }) - .unwrap(); - assert_eq!(ack_deadline_only, serde_json::json!({"ackDeadline": 30})); -} - -#[test] -fn deserialize_clickpipe_source_with_pubsub() { - let json = r#"{ - "pubsub": { - "topic": "projects/p/topics/t", - "projectId": "p", - "authentication": "SERVICE_ACCOUNT", - "format": "JSONEachRow", - "seekType": "latest" - } - }"#; - let src: ClickPipeSource = serde_json::from_str(json).unwrap(); - let pubsub = src.pubsub.expect("pubsub field should populate"); - assert_eq!(pubsub.topic.as_deref(), Some("projects/p/topics/t")); - assert_eq!( - pubsub.format, - Some(ClickPipePubSubSourceFormat::JSONEachRow) - ); -} - -// =========================================================================== -// ClickStack dashboard containers, on-click (issue #203 drift) -// =========================================================================== - -#[test] -fn deserialize_clickstack_dashboard_with_containers() { - let json = r#"{ - "id": "dash-1", - "name": "Overview", - "tiles": [ - { - "id": "tile-1", - "name": "T1", - "x": 0, "y": 0, "w": 4, "h": 4, - "containerId": "c-1", - "tabId": "t-1" - } - ], - "filters": [], - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-02T00:00:00Z", - "containers": [ - { - "id": "c-1", - "title": "Container A", - "collapsed": false, - "tabs": [{"id": "t-1", "title": "Tab 1"}] - } - ] - }"#; - let dash: ClickStackDashboardResponse = serde_json::from_str(json).unwrap(); - let containers = dash.containers.expect("containers should populate"); - assert_eq!(containers.len(), 1); - assert_eq!(containers[0].id.as_deref(), Some("c-1")); - assert_eq!(containers[0].collapsed, Some(false)); - let tabs = containers[0].tabs.as_ref().expect("tabs populated"); - assert_eq!(tabs[0].title.as_deref(), Some("Tab 1")); - let tiles = dash.tiles.expect("tiles should populate"); - assert_eq!(tiles[0].container_id.as_deref(), Some("c-1")); - assert_eq!(tiles[0].tab_id.as_deref(), Some("t-1")); -} - -#[test] -fn deserialize_clickstack_on_click_search_variant() { - // ClickStackOnClick is an untagged enum; the Search variant comes first - // so a "search"-typed payload deserializes through it cleanly. - let json = r#"{ - "type": "search", - "target": {"mode": "id", "id": "search-1"} - }"#; - let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); - match on_click { - ClickStackOnClick::ClickStackOnClickSearch(s) => { - assert_eq!(s.r#type, ClickStackOnClickSearchType::Search); - } - other => panic!("expected search variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_on_click_dashboard_struct() { - // Regression: Search and Dashboard are structurally identical (both require - // `target` + `type`), so before manual discriminator dispatch a - // `type: "dashboard"` payload misdispatched to the Search variant. The union - // now routes on the `type` discriminator. - let json = r#"{ - "type": "dashboard", - "target": {"mode": "template", "template": "{{x}}"}, - "whereLanguage": "sql", - "whereTemplate": "x = {{y}}" - }"#; - let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); - let dash = match on_click { - ClickStackOnClick::ClickStackOnClickDashboard(dash) => dash, - other => panic!("expected dashboard variant, got {other}"), - }; - assert_eq!(dash.r#type, ClickStackOnClickDashboardType::Dashboard); - assert_eq!(dash.where_template.as_deref(), Some("x = {{y}}")); - match dash.target { - ClickStackOnClickTarget::ClickStackOnClickTargetTemplateVariant(t) => { - assert_eq!(t.template, "{{x}}"); - } - other => panic!("expected template target, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_on_click_target_id_variant() { - let json = r#"{"mode": "id", "id": "abc"}"#; - let target: ClickStackOnClickTarget = serde_json::from_str(json).unwrap(); - match target { - ClickStackOnClickTarget::ClickStackOnClickTargetIdVariant(v) => { - assert_eq!(v.id, "abc"); - assert_eq!(v.mode, ClickStackOnClickTargetIdVariantMode::Id); - } - other => panic!("expected id variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_on_click_target_template_variant() { - let json = r#"{"mode": "template", "template": "{{q}}"}"#; - let target: ClickStackOnClickTarget = serde_json::from_str(json).unwrap(); - match target { - ClickStackOnClickTarget::ClickStackOnClickTargetTemplateVariant(v) => { - assert_eq!(v.template, "{{q}}"); - assert_eq!(v.mode, ClickStackOnClickTargetTemplateVariantMode::Template); - } - other => panic!("expected template variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_on_click_target_unknown_shape_round_trips() { - // A payload matching neither the id nor template variant must land in the - // lossless Unknown catch-all and re-serialize to the same JSON object rather - // than erroring on deserialize. - let json = r#"{ - "mode": "future_mode", - "foo": 1 - }"#; - assert_unknown_variant_round_trips(json, |t: &ClickStackOnClickTarget| { - matches!(t, ClickStackOnClickTarget::Unknown(_)) - }); -} - -#[test] -fn deserialize_clickstack_on_click_external_round_trip() { - let json = r#"{"type": "external", "urlTemplate": "https://example.com/{{ServiceName}}"}"#; - let ext: ClickStackOnClickExternal = serde_json::from_str(json).unwrap(); - assert_eq!(ext.r#type, ClickStackOnClickExternalType::External); - assert_eq!(ext.url_template, "https://example.com/{{ServiceName}}"); - let v = serde_json::to_value(&ext).unwrap(); - assert_eq!(v["type"], "external"); - assert_eq!(v["urlTemplate"], "https://example.com/{{ServiceName}}"); -} - -#[test] -fn deserialize_clickstack_on_click_dispatches_external() { - // An "external" on-click payload has no `target`, so it cannot match the - // Search or Dashboard variants (both require `target`) and dispatches - // through the untagged union to the External variant. - let json = r#"{"type": "external", "urlTemplate": "https://example.com/{{value}}"}"#; - let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); - match &on_click { - ClickStackOnClick::ClickStackOnClickExternal(ext) => { - assert_eq!(ext.r#type, ClickStackOnClickExternalType::External); - assert_eq!(ext.url_template, "https://example.com/{{value}}"); - } - other => panic!("expected external variant, got {other}"), - } - // Round-trips back to the same wire shape through the union. - let v = serde_json::to_value(&on_click).unwrap(); - assert_eq!(v["type"], "external"); - assert_eq!(v["urlTemplate"], "https://example.com/{{value}}"); -} - -#[test] -fn deserialize_clickstack_on_click_dispatches_search() { - // Regression: adding the External variant must not steal the Search shape. - let json = r#"{ - "type": "search", - "target": {"mode": "id", "id": "search-1"} - }"#; - let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); - match on_click { - ClickStackOnClick::ClickStackOnClickSearch(s) => { - assert_eq!(s.r#type, ClickStackOnClickSearchType::Search); - } - other => panic!("expected search variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_on_click_dashboard_still_parses() { - // Regression: a `type: "dashboard"` payload dispatches to the Dashboard - // variant through the union rather than being greedily absorbed by the - // structurally identical Search variant. - let json = r#"{ - "type": "dashboard", - "target": {"mode": "template", "template": "{{x}}"} - }"#; - let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); - match on_click { - ClickStackOnClick::ClickStackOnClickDashboard(dash) => { - assert_eq!(dash.r#type, ClickStackOnClickDashboardType::Dashboard); - } - other => panic!("expected dashboard variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_on_click_unknown_type_round_trip() { - // An unrecognized `type` discriminator falls back to the Unknown variant, - // which stores the raw JSON so it round-trips faithfully. - let json = r#"{"type":"popover","payload":{"nested":[1,2,3]}}"#; - let on_click: ClickStackOnClick = serde_json::from_str(json).unwrap(); - match &on_click { - ClickStackOnClick::Unknown(v) => { - assert_eq!(v["type"], "popover"); - assert_eq!(v["payload"]["nested"][2], 3); - } - other => panic!("expected unknown variant, got {other}"), - } - let expected: serde_json::Value = serde_json::from_str(json).unwrap(); - assert_eq!(serde_json::to_value(&on_click).unwrap(), expected); -} - -#[test] -fn clickstack_alert_channel_known_variants_deserialize() { - let email_json = r#"{"emailRecipients": ["a@b.com"], "type": "email"}"#; - let email: ClickStackAlertChannel = serde_json::from_str(email_json).unwrap(); - match email { - ClickStackAlertChannel::ClickStackAlertChannelEmail(v) => { - assert_eq!(v.email_recipients, vec!["a@b.com".to_string()]); - assert_eq!(v.r#type, ClickStackAlertChannelEmailType::Email); - } - other => panic!("expected email variant, got {other}"), - } - - let webhook_json = r#"{"webhookId": "wh-1", "type": "webhook"}"#; - let webhook: ClickStackAlertChannel = serde_json::from_str(webhook_json).unwrap(); - match webhook { - ClickStackAlertChannel::ClickStackAlertChannelWebhook(v) => { - assert_eq!(v.webhook_id, "wh-1"); - assert_eq!(v.r#type, ClickStackAlertChannelWebhookType::Webhook); - } - other => panic!("expected webhook variant, got {other}"), - } -} - -#[test] -fn clickstack_alert_channel_response_known_type_sparse_payload_is_tolerant() { - // A recognized `type` dispatches hard to its variant, and the response - // variant's all-`Option` fields are tolerant: a server that drops - // `emailRecipients` surfaces `None` rather than failing the response. - let json = r#"{"type":"email"}"#; - let channel: ClickStackAlertChannelResponse = serde_json::from_str(json).unwrap(); - match channel { - ClickStackAlertChannelResponse::ClickStackAlertChannelEmail(v) => { - assert_eq!(v.r#type, Some(ClickStackAlertChannelEmailType::Email)); - assert_eq!(v.email_recipients, None); - } - other => panic!("expected email variant, got {other}"), - } - // The request variant stays strict: the same sparse payload is not a valid - // channel to *send*. - assert!(serde_json::from_str::(r#"{"type":"email"}"#).is_err()); -} - -#[test] -fn clickstack_alert_channel_missing_type_key_is_unknown() { - // This union has no arm for an absent `type` key, in deliberate contrast to - // the chart-config unions where absence means the Builder variant, so a - // payload without the discriminator lands in the lossless Unknown catch-all. - let json = r#"{"webhookId":"wh-1"}"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChannel| { - matches!(c, ClickStackAlertChannel::Unknown(_)) - }); -} - -#[test] -fn clickstack_alert_channel_unknown_shape_round_trips() { - // An unrecognized `type` value must land in the lossless Unknown catch-all - // and re-serialize to the same JSON object rather than erroring on - // deserialize. - let json = r#"{ - "type": "future_channel", - "foo": 1 - }"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChannel| { - matches!(c, ClickStackAlertChannel::Unknown(_)) - }); -} - -#[test] -fn clickstack_alert_channel_changed_field_shape_is_unknown() { - // A recognized `type` whose payload no longer fits the variant — here - // `emailRecipients` as a string instead of an array — must not fail the - // response. `list_alerts` returns a Vec, so one such element would otherwise - // take down the whole call; instead the element lands in Unknown intact. - let json = r#"{"type":"email","emailRecipients":"a@b.c"}"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChannel| { - matches!(c, ClickStackAlertChannel::Unknown(_)) - }); -} - -#[test] -fn clickstack_alert_channel_default_round_trips_to_the_same_variant() { - // Both alert-channel variants declare the same `enum: ["webhook", "email"]` - // for their discriminating `type`, so the email variant's default must name - // `email`: `channel` is defaulted on the alert response, and a default that - // named `webhook` would come back from its own union as the webhook variant - // and could be PUT back as a webhook channel with no `webhookId`. - let default = ClickStackAlertChannel::default(); - assert!(matches!( - default, - ClickStackAlertChannel::ClickStackAlertChannelEmail(_) - )); - let json = serde_json::to_string(&default).unwrap(); - assert_eq!(json, r#"{"emailRecipients":[],"type":"email"}"#); - let round_tripped: ClickStackAlertChannel = serde_json::from_str(&json).unwrap(); - assert_eq!(round_tripped, default); - - // A response that drops `channel` entirely surfaces the absence instead of - // fabricating a default channel. - let response: ClickStackAlertResponse = serde_json::from_str(r#"{"name":"my-alert"}"#).unwrap(); - assert_eq!(response.channel, None); -} - -#[test] -fn deserialize_clickstack_log_source_with_metadata_materialized_views() { - let json = r#"{ - "id": "src-1", - "kind": "log", - "name": "logs", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "from": {"databaseName": "default", "tableName": "logs"}, - "timestampValueExpression": "ts", - "metadataMaterializedViews": { - "granularity": "1 hour", - "keyRollupTable": "logs_keys_1h", - "kvRollupTable": "logs_kv_1h" - } - }"#; - let src: ClickStackLogSourceResponse = serde_json::from_str(json).unwrap(); - let mv = src - .metadata_materialized_views - .expect("metadataMaterializedViews should populate"); - assert_eq!(mv.granularity.as_deref(), Some("1 hour")); - assert_eq!(mv.key_rollup_table.as_deref(), Some("logs_keys_1h")); - assert_eq!(mv.kv_rollup_table.as_deref(), Some("logs_kv_1h")); -} - -#[test] -fn deserialize_clickstack_log_source_without_id() { - // `id` is server-generated and omitted from create/update request payloads; - // it must deserialize to None and be dropped on serialize. - let json = r#"{ - "kind": "log", - "name": "logs", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "from": {"databaseName": "default", "tableName": "logs"}, - "timestampValueExpression": "ts" - }"#; - let src: ClickStackLogSource = serde_json::from_str(json).unwrap(); - assert_eq!(src.id, None); - let v = serde_json::to_value(&src).unwrap(); - assert!(v.get("id").is_none(), "id must be omitted when None"); -} - -#[test] -fn deserialize_clickstack_trace_source_default_table_select_expression() { - let json = r#"{ - "id": "trace-1", - "kind": "trace", - "name": "traces", - "connection": "conn-1", - "defaultTableSelectExpression": "Timestamp, SpanName", - "from": {"databaseName": "default", "tableName": "traces"}, - "timestampValueExpression": "Timestamp", - "durationExpression": "Duration", - "durationPrecision": 9, - "traceIdExpression": "TraceId", - "spanIdExpression": "SpanId", - "parentSpanIdExpression": "ParentSpanId", - "spanNameExpression": "SpanName", - "spanKindExpression": "SpanKind" - }"#; - let src: ClickStackTraceSource = serde_json::from_str(json).unwrap(); - assert_eq!(src.default_table_select_expression, "Timestamp, SpanName"); - let v = serde_json::to_value(&src).unwrap(); - assert_eq!(v["defaultTableSelectExpression"], "Timestamp, SpanName"); - - // `defaultTableSelectExpression` is in the spec `required[]` for trace - // sources, so the request variant types it as `String`. Responses stay - // tolerant of a server-side field drop through the response variant, where - // it is `Option`: a missing field lands as `None` instead of - // failing the whole payload (and, via the `kind`-dispatched - // `ClickStackSourceResponse` union, the whole list response). - let missing = r#"{ - "id": "trace-1", - "kind": "trace", - "name": "traces", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "traces"}, - "timestampValueExpression": "Timestamp", - "durationExpression": "Duration", - "durationPrecision": 9, - "traceIdExpression": "TraceId", - "spanIdExpression": "SpanId", - "parentSpanIdExpression": "ParentSpanId", - "spanNameExpression": "SpanName", - "spanKindExpression": "SpanKind" - }"#; - let src: ClickStackTraceSourceResponse = serde_json::from_str(missing).unwrap(); - assert_eq!(src.default_table_select_expression, None); - match serde_json::from_str::(missing).unwrap() { - ClickStackSourceResponse::ClickStackTraceSource(src) => { - assert_eq!(src.default_table_select_expression, None); - } - other => panic!("expected trace variant, got {other:?}"), - } - // The dropped field is absent from the re-serialized payload rather than - // sent back as `null`. - let v = serde_json::to_value(&src).unwrap(); - assert!(v.get("defaultTableSelectExpression").is_none()); -} - -#[test] -fn round_trip_clickstack_log_source_new_fields() { - let json = r#"{ - "id": "src-1", - "kind": "log", - "name": "logs", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "from": {"databaseName": "default", "tableName": "logs"}, - "timestampValueExpression": "ts", - "section": "Billing", - "disabled": false, - "knownColumnsListExpression": "Timestamp, Body, ServiceName", - "useTextIndexForImplicitColumn": "auto" - }"#; - let src: ClickStackLogSource = serde_json::from_str(json).unwrap(); - assert_eq!(src.section.as_deref(), Some("Billing")); - assert_eq!(src.disabled, Some(false)); - assert_eq!( - src.known_columns_list_expression.as_deref(), - Some("Timestamp, Body, ServiceName") - ); - assert_eq!( - src.use_text_index_for_implicit_column, - Some(ClickStackLogSourceUsetextindexforimplicitcolumn::Auto) - ); - let v = serde_json::to_value(&src).unwrap(); - assert_eq!(v["section"], "Billing"); - assert_eq!(v["disabled"], false); - assert_eq!( - v["knownColumnsListExpression"], - "Timestamp, Body, ServiceName" - ); - assert_eq!(v["useTextIndexForImplicitColumn"], "auto"); -} - -#[test] -fn clickstack_source_dispatches_promql_variant() { - let json = r#"{ - "id": "src-1", - "kind": "promql", - "name": "Prometheus Metrics", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "metrics"}, - "timestampValueExpression": "timestamp" - }"#; - let source: ClickStackSource = serde_json::from_str(json).unwrap(); - match source { - ClickStackSource::ClickStackPromqlSource(src) => { - assert_eq!(src.kind, ClickStackPromqlSourceKind::Promql); - assert_eq!(src.name, "Prometheus Metrics"); - assert_eq!(src.connection, "conn-1"); - assert_eq!(src.id.as_deref(), Some("src-1")); - } - other => panic!("expected Promql source, got {other}"), - } -} - -#[test] -fn clickstack_source_dispatches_log_variant() { - // Regression: a log-source payload must not be swallowed by the new Promql - // variant and must still resolve to the log-source variant. - let json = r#"{ - "id": "src-1", - "kind": "log", - "name": "logs", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "from": {"databaseName": "default", "tableName": "logs"}, - "timestampValueExpression": "ts" - }"#; - let source: ClickStackSource = serde_json::from_str(json).unwrap(); - match source { - ClickStackSource::ClickStackLogSource(src) => { - assert_eq!(src.kind, ClickStackLogSourceKind::Log); - assert_eq!(src.name, "logs"); - } - other => panic!("expected log source, got {other}"), - } -} - -#[test] -fn clickstack_source_dispatches_trace_variant() { - // Regression (PR #311 review P1-1): a spec-valid trace payload shares every - // Log-required field (defaultTableSelectExpression et al.), so untagged - // matching would greedily resolve it to the Log variant and drop trace-only - // fields. Discriminator dispatch on `kind` must route it to the Trace - // variant with its trace-only fields intact. - let json = r#"{ - "id": "trace-1", - "kind": "trace", - "name": "traces", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "traces"}, - "timestampValueExpression": "Timestamp", - "defaultTableSelectExpression": "Timestamp, SpanName", - "durationExpression": "Duration", - "durationPrecision": 9, - "parentSpanIdExpression": "ParentSpanId", - "spanIdExpression": "SpanId", - "spanKindExpression": "SpanKind", - "spanNameExpression": "SpanName", - "traceIdExpression": "TraceId" - }"#; - let source: ClickStackSource = serde_json::from_str(json).unwrap(); - match source { - ClickStackSource::ClickStackTraceSource(src) => { - assert_eq!(src.kind, ClickStackTraceSourceKind::Trace); - assert_eq!(src.name, "traces"); - assert_eq!(src.default_table_select_expression, "Timestamp, SpanName"); - assert_eq!(src.duration_expression, "Duration"); - assert_eq!(src.duration_precision, 9); - assert_eq!(src.parent_span_id_expression, "ParentSpanId"); - assert_eq!(src.span_id_expression, "SpanId"); - assert_eq!(src.span_kind_expression, "SpanKind"); - assert_eq!(src.span_name_expression, "SpanName"); - assert_eq!(src.trace_id_expression, "TraceId"); - } - other => panic!("expected trace source, got {other}"), - } -} - -#[test] -fn clickstack_source_dispatches_metric_variant() { - let json = r#"{ - "id": "metric-1", - "kind": "metric", - "name": "metrics", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "metrics"}, - "timestampValueExpression": "TimeUnix", - "resourceAttributesExpression": "ResourceAttributes", - "metricTables": { - "gauge": "otel_metrics_gauge", - "histogram": "otel_metrics_histogram", - "sum": "otel_metrics_sum", - "summary": "otel_metrics_summary", - "exponential histogram": "otel_metrics_exponential_histogram" - } - }"#; - let source: ClickStackSource = serde_json::from_str(json).unwrap(); - match source { - ClickStackSource::ClickStackMetricSource(src) => { - assert_eq!(src.kind, ClickStackMetricSourceKind::Metric); - assert_eq!(src.name, "metrics"); - assert_eq!(src.resource_attributes_expression, "ResourceAttributes"); - assert_eq!(src.metric_tables.gauge, "otel_metrics_gauge"); - } - other => panic!("expected metric source, got {other}"), - } -} - -#[test] -fn clickstack_source_dispatches_session_variant() { - let json = r#"{ - "id": "session-1", - "kind": "session", - "name": "sessions", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "sessions"}, - "traceSourceId": "trace-1" - }"#; - let source: ClickStackSource = serde_json::from_str(json).unwrap(); - match source { - ClickStackSource::ClickStackSessionSource(src) => { - assert_eq!(src.kind, ClickStackSessionSourceKind::Session); - assert_eq!(src.name, "sessions"); - assert_eq!(src.trace_source_id, "trace-1"); - } - other => panic!("expected session source, got {other}"), - } -} - -#[test] -fn clickstack_source_unknown_kind_round_trips() { - let json = r#"{"kind":"future_kind","name":"x"}"#; - assert_unknown_variant_round_trips(json, |s: &ClickStackSource| { - matches!(s, ClickStackSource::Unknown(_)) - }); -} - -#[test] -fn clickstack_source_serializes_as_inner_log_struct() { - let log = ClickStackLogSource { - kind: ClickStackLogSourceKind::Log, - name: "logs".to_string(), - connection: "conn-1".to_string(), - default_table_select_expression: "*".to_string(), - from: ClickStackSourceFrom { - database_name: "default".to_string(), - table_name: "logs".to_string(), - }, - timestamp_value_expression: "ts".to_string(), - ..Default::default() - }; - let source = ClickStackSource::ClickStackLogSource(log.clone()); - assert_eq!( - serde_json::to_value(&source).unwrap(), - serde_json::to_value(&log).unwrap() - ); -} - -#[test] -fn clickstack_source_response_dispatches_on_kind_alone() { - // The response variants are all-`Option`, so every one of them matches any - // JSON object: under `untagged` shape matching the first arm would swallow - // all five kinds. Dispatch reads `kind` off the raw JSON instead, so a - // payload carrying nothing but its discriminator still resolves to the right - // variant. - for (kind, expected) in [ - ("log", "ClickStackLogSource"), - ("trace", "ClickStackTraceSource"), - ("metric", "ClickStackMetricSource"), - ("session", "ClickStackSessionSource"), - ("promql", "ClickStackPromqlSource"), - ] { - let json = format!(r#"{{"kind":"{kind}"}}"#); - let source: ClickStackSourceResponse = serde_json::from_str(&json).unwrap(); - assert_eq!(source.to_string(), expected, "wrong variant for {kind}"); - } -} - -#[test] -fn clickstack_source_response_treats_dropped_and_null_fields_as_absent() { - // Both halves of the tolerance contract on one payload: `connection` is sent - // as an explicit `null` (which `serde(default)` would not have absorbed) and - // every other field of the schema is dropped outright. - let json = r#"{"kind":"log","connection":null,"name":"logs"}"#; - let source: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); - let ClickStackSourceResponse::ClickStackLogSource(log) = source else { - panic!("expected the log variant"); - }; - assert_eq!(log.connection, None); - assert_eq!(log.from, None); - assert_eq!(log.timestamp_value_expression, None); - assert_eq!(log.name.as_deref(), Some("logs")); - - // Absent fields are omitted on the way out, not re-emitted as `null`. - let v = serde_json::to_value(&log).unwrap(); - assert_eq!(v, serde_json::json!({"kind": "log", "name": "logs"})); -} - -#[test] -fn clickstack_source_response_unknown_kind_round_trips() { - let json = r#"{"kind":"future_kind","name":"x"}"#; - assert_unknown_variant_round_trips(json, |s: &ClickStackSourceResponse| { - matches!(s, ClickStackSourceResponse::Unknown(_)) - }); -} - -#[test] -fn clickstack_source_response_nested_objects_are_tolerant() { - // The nested objects of a source are response variants too, so a dropped - // field inside `from`, `filterSettings` or `materializedViews` does not fail - // the source. - let json = r#"{ - "kind": "log", - "from": {"databaseName": "default"}, - "filterSettings": {"columns": [{"name": "ServiceName"}]}, - "materializedViews": [{"tableName": "logs_1h", "aggregatedColumns": [{"aggFn": "sum"}]}], - "querySettings": [{"setting": "max_threads"}] - }"#; - let source: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); - let ClickStackSourceResponse::ClickStackLogSource(log) = source else { - panic!("expected the log variant"); - }; - let from = log.from.expect("from present"); - assert_eq!(from.database_name.as_deref(), Some("default")); - assert_eq!(from.table_name, None); - let filter_settings = log.filter_settings.expect("filterSettings present"); - assert_eq!(filter_settings.table_name, None); - let columns = filter_settings.columns.expect("columns present"); - assert_eq!(columns[0].name.as_deref(), Some("ServiceName")); - assert_eq!(columns[0].label, None); - let views = log.materialized_views.expect("materializedViews present"); - assert_eq!(views[0].min_granularity, None); - let aggregated = views[0] - .aggregated_columns - .as_deref() - .expect("aggregatedColumns present"); - assert_eq!(aggregated[0].agg_fn.as_deref(), Some("sum")); - assert_eq!(aggregated[0].mv_column, None); - let query_settings = log.query_settings.expect("querySettings present"); - assert_eq!(query_settings[0].value, None); -} - -#[test] -fn clickstack_source_try_from_response_converts_every_kind() { - // One spec-complete payload per kind, each carrying the nested objects that - // kind owns, so every conversion in the source tree is exercised: a source - // fetched and written back unchanged must produce the JSON it came from. - let payloads = [ - serde_json::json!({ - "kind": "log", - "name": "logs", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "from": {"databaseName": "default", "tableName": "logs"}, - "timestampValueExpression": "ts", - "filterSettings": { - "columns": [{"label": "Service", "name": "ServiceName"}], - "databaseName": "default", - "tableName": "logs_filters", - }, - "highlightedRowAttributeExpressions": [{"sqlExpression": "ServiceName"}], - "materializedViews": [{ - "aggregatedColumns": [{"aggFn": "sum", "mvColumn": "count"}], - "databaseName": "default", - "dimensionColumns": "ServiceName", - "minGranularity": "1 hour", - "tableName": "logs_1h", - "timestampColumn": "ts", - }], - "metadataMaterializedViews": { - "granularity": "1 hour", - "keyRollupTable": "logs_keys_1h", - "kvRollupTable": "logs_kv_1h", - }, - "orderByExpression": "Timestamp DESC", - "querySettings": [{"setting": "max_threads", "value": "4"}], - }), - serde_json::json!({ - "kind": "trace", - "name": "traces", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "durationExpression": "Duration", - "durationPrecision": 9, - "from": {"databaseName": "default", "tableName": "traces"}, - "parentSpanIdExpression": "ParentSpanId", - "spanIdExpression": "SpanId", - "spanKindExpression": "SpanKind", - "spanNameExpression": "SpanName", - "timestampValueExpression": "Timestamp", - "traceIdExpression": "TraceId", - "displayedTimestampValueExpression": "Timestamp", - "orderByExpression": "Timestamp DESC", - "sampleRateExpression": "SampleRate", - "spanLinksValueExpression": "Links", - "metadataMaterializedViews": { - "granularity": "1 hour", - "keyRollupTable": "traces_keys_1h", - "kvRollupTable": "traces_kv_1h", - }, - }), - serde_json::json!({ - "kind": "metric", - "name": "metrics", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "metrics"}, - "metricTables": { - "exponential histogram": "otel_metrics_exponential_histogram", - "gauge": "otel_metrics_gauge", - "histogram": "otel_metrics_histogram", - "sum": "otel_metrics_sum", - "summary": "otel_metrics_summary", - }, - "resourceAttributesExpression": "ResourceAttributes", - "timestampValueExpression": "TimeUnix", - }), - serde_json::json!({ - "kind": "session", - "name": "sessions", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "sessions"}, - "resourceAttributesExpression": "ResourceAttributes", - "traceSourceId": "trace-1", - }), - serde_json::json!({ - "kind": "promql", - "name": "prometheus", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "metrics"}, - "timestampValueExpression": "timestamp", - }), - ]; - - for payload in payloads { - let response: ClickStackSourceResponse = serde_json::from_value(payload.clone()).unwrap(); - let request = ClickStackSource::try_from(response) - .unwrap_or_else(|e| panic!("{} should convert: {e}", payload["kind"])); - assert_eq!(serde_json::to_value(&request).unwrap(), payload); - } -} - -#[test] -fn clickstack_source_try_from_response_converts_a_complete_source() { - let json = r#"{ - "id": "src-1", - "kind": "log", - "name": "logs", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "from": {"databaseName": "default", "tableName": "logs"}, - "timestampValueExpression": "ts", - "querySettings": [{"setting": "max_threads", "value": "4"}] - }"#; - let response: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); - let request = ClickStackSource::try_from(response).expect("conversion should succeed"); - let ClickStackSource::ClickStackLogSource(log) = &request else { - panic!("expected the log variant"); - }; - assert_eq!(log.connection, "conn-1"); - assert_eq!(log.from.table_name, "logs"); - assert_eq!( - log.query_settings.as_deref(), - Some( - [ClickStackQuerySetting { - setting: "max_threads".to_string(), - value: "4".to_string(), - }] - .as_slice() - ) - ); - // A write-back of an untouched source is byte-identical to what was fetched. - assert_eq!( - serde_json::to_value(&request).unwrap(), - serde_json::from_str::(json).unwrap() - ); -} - -#[test] -fn clickstack_source_try_from_response_names_every_missing_required_field() { - let response: ClickStackSourceResponse = - serde_json::from_str(r#"{"kind":"log","name":"logs"}"#).unwrap(); - let error = ClickStackSource::try_from(response).expect_err("conversion should fail"); - assert_eq!( - error.fields(), - [ - "connection", - "defaultTableSelectExpression", - "from", - "timestampValueExpression" - ] - ); - assert_eq!( - error.to_string(), - "the API response is missing required field(s): connection, \ - defaultTableSelectExpression, from, timestampValueExpression" - ); -} - -#[test] -fn clickstack_source_try_from_response_reports_a_nested_missing_field() { - // A nested object reports its own wire name: `from` is present, so the - // failure is `tableName` inside it. - let json = r#"{ - "kind": "log", - "name": "logs", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "from": {"databaseName": "default"}, - "timestampValueExpression": "ts" - }"#; - let response: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); - let error = ClickStackSource::try_from(response).expect_err("conversion should fail"); - assert_eq!(error.fields(), ["tableName"]); - - // The same holds for an element of a nested list. - let json = r#"{ - "kind": "log", - "name": "logs", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "from": {"databaseName": "default", "tableName": "logs"}, - "timestampValueExpression": "ts", - "querySettings": [{"setting": "max_threads"}] - }"#; - let response: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); - let error = ClickStackSource::try_from(response).expect_err("conversion should fail"); - assert_eq!(error.fields(), ["value"]); -} - -#[test] -fn clickstack_source_try_from_response_passes_an_unknown_kind_through() { - // A source kind this crate does not model must still be writable back: the - // request union's `Unknown` arm holds the raw payload and serializes it - // verbatim. - let json = r#"{"kind":"future_kind","name":"x"}"#; - let response: ClickStackSourceResponse = serde_json::from_str(json).unwrap(); - let request = ClickStackSource::try_from(response).expect("Unknown converts losslessly"); - assert!(matches!(request, ClickStackSource::Unknown(_))); - assert_eq!( - serde_json::to_value(&request).unwrap(), - serde_json::from_str::(json).unwrap() - ); -} - -#[test] -fn shared_clickstack_source_types_stay_strict_on_the_request_side() { - // Sources are sent as well as returned, so each type in the source tree - // splits: the request variant keeps the schema's required fields as `T` and - // rejects a payload that omits one, while the response variant accepts any - // object. - macro_rules! assert_split_strictness { - ($($request:ty => $response:ty),+ $(,)?) => { - $( - assert!( - serde_json::from_str::<$request>("{}").is_err(), - "{} accepted a payload missing its required fields", - stringify!($request), - ); - assert_eq!( - serde_json::from_str::<$response>("{}").unwrap(), - <$response>::default(), - "{} rejected an empty payload", - stringify!($response), - ); - )+ - }; - } - - assert_split_strictness!( - ClickStackAggregatedColumn => ClickStackAggregatedColumnResponse, - ClickStackClickHouseCASLPermission => ClickStackClickHouseCASLPermissionResponse, - ClickStackClickHouseCASLPermissionConditions => ClickStackClickHouseCASLPermissionConditionsResponse, - ClickStackClickHouseCASLPermissionConditionsFrom => ClickStackClickHouseCASLPermissionConditionsFromResponse, - ClickStackMongoDBCASLPermission => ClickStackMongoDBCASLPermissionResponse, - ClickStackQueryExpressionFilter => ClickStackQueryExpressionFilterResponse, - ClickStackStaticListFilter => ClickStackStaticListFilterResponse, - ClickStackPrometheusLabelFilter => ClickStackPrometheusLabelFilterResponse, - ClickStackSourceFilter => ClickStackSourceFilterResponse, - ClickStackFilterSettingsColumn => ClickStackFilterSettingsColumnResponse, - ClickStackHighlightedAttributeExpression => ClickStackHighlightedAttributeExpressionResponse, - ClickStackLogSource => ClickStackLogSourceResponse, - ClickStackLogSourceMetadataMaterializedViews => ClickStackLogSourceMetadataMaterializedViewsResponse, - ClickStackMaterializedView => ClickStackMaterializedViewResponse, - ClickStackMetricSource => ClickStackMetricSourceResponse, - ClickStackMetricSourceFrom => ClickStackMetricSourceFromResponse, - ClickStackMetricTables => ClickStackMetricTablesResponse, - ClickStackPromqlSource => ClickStackPromqlSourceResponse, - ClickStackQuerySetting => ClickStackQuerySettingResponse, - ClickStackSqlSavedFilterValue => ClickStackSqlSavedFilterValueResponse, - ClickStackVariableSavedFilterValue => ClickStackVariableSavedFilterValueResponse, - ClickStackFormula => ClickStackFormulaResponse, - ClickStackSavedSearchFilter => ClickStackSavedSearchFilterResponse, - ClickStackSessionSource => ClickStackSessionSourceResponse, - ClickStackSourceFilterSettings => ClickStackSourceFilterSettingsResponse, - ClickStackSourceFrom => ClickStackSourceFromResponse, - ClickStackTraceSource => ClickStackTraceSourceResponse, - ClickStackTraceSourceMetadataMaterializedViews => ClickStackTraceSourceMetadataMaterializedViewsResponse, - ); -} - -#[test] -fn clickstack_response_only_models_accept_an_empty_payload() { - // Connections, roles and saved searches are only ever returned — the create - // and update bodies are separate schemas — so they are all-`Option` in place - // instead of splitting, and none of their fields can fail a response. - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickStackConnection::default() - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickStackRole::default() - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickStackSavedSearch::default() - ); - // Their request counterparts stay strict. - assert!(serde_json::from_str::("{}").is_err()); - assert!(serde_json::from_str::("{}").is_err()); - assert!(serde_json::from_str::("{}").is_err()); -} - -#[test] -fn shared_clickstack_dashboard_types_stay_strict_on_the_request_side() { - // Dashboard containers, tiles' chart configs, select items, number formats, - // color conditions, on-click targets, alert channels and alert chart configs - // are sent as well as returned, so each splits: the request variant keeps - // the schema's required fields as `T` and rejects a payload that omits one, - // while the response variant accepts any object. - macro_rules! assert_split_strictness { - ($($request:ty => $response:ty),+ $(,)?) => { - $( - assert!( - serde_json::from_str::<$request>("{}").is_err(), - "{} accepted a payload missing its required fields", - stringify!($request), - ); - assert_eq!( - serde_json::from_str::<$response>("{}").unwrap(), - <$response>::default(), - "{} rejected an empty payload", - stringify!($response), - ); - )+ - }; - } - - assert_split_strictness!( - ClickStackAlertBarBuilderChartConfig => ClickStackAlertBarBuilderChartConfigResponse, - ClickStackAlertBarRawSqlChartConfig => ClickStackAlertBarRawSqlChartConfigResponse, - ClickStackAlertLineBuilderChartConfig => ClickStackAlertLineBuilderChartConfigResponse, - ClickStackAlertLineRawSqlChartConfig => ClickStackAlertLineRawSqlChartConfigResponse, - ClickStackAlertNumberBuilderChartConfig => ClickStackAlertNumberBuilderChartConfigResponse, - ClickStackAlertNumberRawSqlChartConfig => ClickStackAlertNumberRawSqlChartConfigResponse, - ClickStackAlertStackedLineBuilderChartConfig => ClickStackAlertStackedLineBuilderChartConfigResponse, - ClickStackAlertStackedLineRawSqlChartConfig => ClickStackAlertStackedLineRawSqlChartConfigResponse, - ClickStackAlertChannelEmail => ClickStackAlertChannelEmailResponse, - ClickStackAlertChannelWebhook => ClickStackAlertChannelWebhookResponse, - ClickStackBackgroundChart => ClickStackBackgroundChartResponse, - ClickStackBarBuilderChartConfig => ClickStackBarBuilderChartConfigResponse, - ClickStackBarRawSqlChartConfig => ClickStackBarRawSqlChartConfigResponse, - ClickStackBetweenColorCondition => ClickStackBetweenColorConditionResponse, - ClickStackCategoricalBarBuilderChartConfig => ClickStackCategoricalBarBuilderChartConfigResponse, - ClickStackCategoricalBarRawSqlChartConfig => ClickStackCategoricalBarRawSqlChartConfigResponse, - ClickStackDashboardContainer => ClickStackDashboardContainerResponse, - ClickStackDashboardContainerTab => ClickStackDashboardContainerTabResponse, - ClickStackEqualityColorCondition => ClickStackEqualityColorConditionResponse, - ClickStackEventPatternsChartConfig => ClickStackEventPatternsChartConfigResponse, - ClickStackHeatmapDistributionChartConfig => ClickStackHeatmapDistributionChartConfigResponse, - ClickStackHeatmapSeriesChartConfig => ClickStackHeatmapSeriesChartConfigResponse, - ClickStackHeatmapSelectItem => ClickStackHeatmapSelectItemResponse, - ClickStackLineBuilderChartConfig => ClickStackLineBuilderChartConfigResponse, - ClickStackLineRawSqlChartConfig => ClickStackLineRawSqlChartConfigResponse, - ClickStackMarkdownChartConfig => ClickStackMarkdownChartConfigResponse, - ClickStackNumberBuilderChartConfig => ClickStackNumberBuilderChartConfigResponse, - ClickStackNumberFormat => ClickStackNumberFormatResponse, - ClickStackNumberRawSqlChartConfig => ClickStackNumberRawSqlChartConfigResponse, - ClickStackNumericColorCondition => ClickStackNumericColorConditionResponse, - ClickStackOnClickDashboard => ClickStackOnClickDashboardResponse, - ClickStackOnClickExternal => ClickStackOnClickExternalResponse, - ClickStackOnClickFilterTemplate => ClickStackOnClickFilterTemplateResponse, - ClickStackOnClickSearch => ClickStackOnClickSearchResponse, - ClickStackOnClickTargetIdVariant => ClickStackOnClickTargetIdVariantResponse, - ClickStackOnClickTargetTemplateVariant => ClickStackOnClickTargetTemplateVariantResponse, - ClickStackPieBuilderChartConfig => ClickStackPieBuilderChartConfigResponse, - ClickStackPieRawSqlChartConfig => ClickStackPieRawSqlChartConfigResponse, - ClickStackSearchChartConfig => ClickStackSearchChartConfigResponse, - ClickStackSelectItem => ClickStackSelectItemResponse, - ClickStackStackedLineBuilderChartConfig => ClickStackStackedLineBuilderChartConfigResponse, - ClickStackStackedLineRawSqlChartConfig => ClickStackStackedLineRawSqlChartConfigResponse, - ClickStackTableBuilderChartConfig => ClickStackTableBuilderChartConfigResponse, - ClickStackTableRawSqlChartConfig => ClickStackTableRawSqlChartConfigResponse, - ); -} - -#[test] -fn clickstack_alert_and_dashboard_response_models_accept_an_empty_payload() { - // Alerts, dashboards, tiles, validation results and webhooks are only ever - // returned — their request bodies are separate schemas — so they are - // all-`Option` in place instead of splitting, and none of their fields can - // fail a response. - macro_rules! assert_accepts_empty { - ($($model:ty),+ $(,)?) => { - $( - assert_eq!( - serde_json::from_str::<$model>("{}").unwrap(), - <$model>::default(), - "{} rejected an empty payload", - stringify!($model), - ); - )+ - }; - } - - assert_accepts_empty!( - ClickStackAlertExecutionError, - ClickStackAlertResponse, - ClickStackAlertSilenced, - ClickStackDashboardResponse, - ClickStackGenericWebhook, - ClickStackIncidentIOWebhook, - ClickStackPagerDutyAPIWebhook, - ClickStackSlackAPIWebhook, - ClickStackSlackWebhook, - ClickStackTileOutput, - ClickStackValidateDashboardError, - ClickStackValidateDashboardResponse, - ClickStackValidateWebhookResponse, - ClickStackValidateWebhookResponseErrorsItem, - ); -} - -#[test] -fn clickstack_dashboard_response_null_fields_deserialize_to_none() { - // An explicit JSON `null` — not just a missing key — must land as `None`: - // `Option` absorbs it natively, which `serde(default)` never did. - let json = r#"{"id":null,"name":null,"tiles":null,"filters":null,"tags":null}"#; - let dash: ClickStackDashboardResponse = serde_json::from_str(json).unwrap(); - assert_eq!(dash, ClickStackDashboardResponse::default()); - // Absent fields are omitted on serialize, not emitted as `null`. - assert_eq!(serde_json::to_value(&dash).unwrap(), serde_json::json!({})); -} - -#[test] -fn clickstack_tile_config_response_raw_sql_body_without_config_type_falls_to_sub_union_unknown() { - // Every field of a response builder variant is `Option`, so the builder arm - // is total and would absorb a raw-SQL payload whose server dropped - // `configType`, silently retyping it and losing `connectionId` and - // `sqlTemplate`. The `none unless` guard routes it to Unknown, which keeps - // the payload verbatim. - let json = r#"{"displayType":"line","connectionId":"conn-1","sqlTemplate":"SELECT 1"}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfigResponse| { - matches!( - cfg, - ClickStackTileConfigResponse::ClickStackLineChartConfig( - ClickStackLineChartConfigResponse::Unknown(_) - ) - ) - }); -} - -#[test] -fn clickstack_tile_config_response_dispatches_raw_sql_variant() { - // A `configType: "sql"` payload dispatches to the raw-SQL response variant - // even when the raw-SQL-required fields are missing: response tolerance is - // per-field, not per-variant. - let json = r#"{"displayType":"line","configType":"sql"}"#; - let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfigResponse::ClickStackLineChartConfig( - ClickStackLineChartConfigResponse::ClickStackLineRawSqlChartConfig(r), - ) => { - assert_eq!(r.connection_id, None); - assert_eq!(r.sql_template, None); - } - other => panic!("expected line raw-SQL variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_response_changed_field_shape_falls_to_sub_union_unknown() { - // A recognized discriminator whose payload no longer fits the variant — - // here `sqlTemplate` as a number — must not fail the response: the element - // lands in the lossless Unknown catch-all and round-trips verbatim. - let json = r#"{"displayType":"line","configType":"sql","sqlTemplate":5}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfigResponse| { - matches!( - cfg, - ClickStackTileConfigResponse::ClickStackLineChartConfig( - ClickStackLineChartConfigResponse::Unknown(_) - ) - ) - }); -} - -#[test] -fn clickstack_tile_config_response_unknown_display_type_round_trips() { - let json = r#"{"displayType":"hologram","select":[]}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfigResponse| { - matches!(cfg, ClickStackTileConfigResponse::Unknown(_)) - }); -} - -#[test] -fn clickstack_alert_channel_response_dispatches_and_absorbs_changed_shapes() { - // Known discriminators dispatch to their response variants. - let email: ClickStackAlertChannelResponse = - serde_json::from_str(r#"{"type":"email","emailRecipients":["a@b.c"]}"#).unwrap(); - match email { - ClickStackAlertChannelResponse::ClickStackAlertChannelEmail(v) => { - assert_eq!( - v.email_recipients.as_deref(), - Some(&["a@b.c".to_string()][..]) - ); - } - other => panic!("expected email variant, got {other}"), - } - // A recognized `type` whose payload no longer fits the variant — here - // `emailRecipients` as a string — lands in Unknown intact. - let json = r#"{"type":"email","emailRecipients":"a@b.c"}"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChannelResponse| { - matches!(c, ClickStackAlertChannelResponse::Unknown(_)) - }); - // As does an unrecognized `type`. - let json = r#"{"type":"future_channel","foo":1}"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChannelResponse| { - matches!(c, ClickStackAlertChannelResponse::Unknown(_)) - }); -} - -#[test] -fn clickstack_on_click_response_dispatches_on_type_and_mode() { - let json = r#"{"type":"external","urlTemplate":"https://example.com/{{id}}"}"#; - let on_click: ClickStackOnClickResponse = serde_json::from_str(json).unwrap(); - match on_click { - ClickStackOnClickResponse::ClickStackOnClickExternal(e) => { - assert_eq!( - e.url_template.as_deref(), - Some("https://example.com/{{id}}") - ); - } - other => panic!("expected external variant, got {other}"), - } - // A search on-click whose server dropped `target` still dispatches; the - // absence is a `None`, not a failure. - let json = r#"{"type":"search"}"#; - let on_click: ClickStackOnClickResponse = serde_json::from_str(json).unwrap(); - match on_click { - ClickStackOnClickResponse::ClickStackOnClickSearch(s) => assert_eq!(s.target, None), - other => panic!("expected search variant, got {other}"), - } - let json = r#"{"mode":"template","template":"{{rowId}}"}"#; - let target: ClickStackOnClickTargetResponse = serde_json::from_str(json).unwrap(); - match target { - ClickStackOnClickTargetResponse::ClickStackOnClickTargetTemplateVariant(t) => { - assert_eq!(t.template.as_deref(), Some("{{rowId}}")); - } - other => panic!("expected template variant, got {other}"), - } - // Missing discriminators land in Unknown: these unions have no absence arm. - let json = r#"{"urlTemplate":"https://example.com"}"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackOnClickResponse| { - matches!(c, ClickStackOnClickResponse::Unknown(_)) - }); - let json = r#"{"template":"{{rowId}}"}"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackOnClickTargetResponse| { - matches!(c, ClickStackOnClickTargetResponse::Unknown(_)) - }); -} - -#[test] -fn clickstack_number_tile_color_condition_response_dispatches_on_operator() { - let json = r#"{"operator":"between","value":[1.0, 2.0]}"#; - let cond: ClickStackNumberTileColorConditionResponse = serde_json::from_str(json).unwrap(); - match cond { - ClickStackNumberTileColorConditionResponse::ClickStackBetweenColorCondition(b) => { - assert_eq!(b.value.as_deref(), Some(&[1.0, 2.0][..])); - assert_eq!(b.color, None); - } - other => panic!("expected between variant, got {other}"), - } - let json = r#"{"operator":"someday","value":7}"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackNumberTileColorConditionResponse| { - matches!(c, ClickStackNumberTileColorConditionResponse::Unknown(_)) - }); -} - -#[test] -fn deserialize_clickstack_alert_with_note() { - let json = r#"{ - "id": "alert-1", - "name": "High CPU", - "note": "investigate runaway queries" - }"#; - let alert: ClickStackAlertResponse = serde_json::from_str(json).unwrap(); - assert_eq!(alert.note.as_deref(), Some("investigate runaway queries")); - // Round-trip the optional `note` to confirm it serializes (no rename, but - // its skip_serializing_if=None gate must let Some(_) through). - let v = serde_json::to_value(&alert).unwrap(); - assert_eq!(v["note"], "investigate runaway queries"); -} - -#[test] -fn autoscaling_mode_round_trip() { - let v = serde_json::to_value(AutoscalingMode::Vertical).unwrap(); - assert_eq!(v, "vertical"); - let v = serde_json::to_value(AutoscalingMode::Horizontal).unwrap(); - assert_eq!(v, "horizontal"); - let parsed: AutoscalingMode = serde_json::from_str("\"vertical\"").unwrap(); - assert_eq!(parsed, AutoscalingMode::Vertical); - let parsed: AutoscalingMode = serde_json::from_str("\"horizontal\"").unwrap(); - assert_eq!(parsed, AutoscalingMode::Horizontal); - assert_eq!(AutoscalingMode::default(), AutoscalingMode::Vertical); - assert_eq!(AutoscalingMode::Vertical.to_string(), "vertical"); - assert_eq!(AutoscalingMode::Horizontal.to_string(), "horizontal"); -} - -#[test] -fn autoscaling_mode_unknown_catch_all() { - let parsed: AutoscalingMode = serde_json::from_str("\"crystal\"").unwrap(); - assert_eq!(parsed, AutoscalingMode::Unknown("crystal".to_string())); - assert_eq!(parsed.to_string(), "crystal"); -} - -#[test] -fn pg_state_property_stopped() { - let parsed: PgStateProperty = serde_json::from_str("\"stopped\"").unwrap(); - assert_eq!(parsed, PgStateProperty::Stopped); - assert_eq!(parsed.to_string(), "stopped"); -} - -#[test] -fn service_region_ca_central_1() { - let parsed: ServiceRegion = serde_json::from_str("\"ca-central-1\"").unwrap(); - assert_eq!(parsed, ServiceRegion::Ca_central_1); - assert_eq!(parsed.to_string(), "ca-central-1"); -} - -#[test] -fn byoc_config_regionid_ca_central_1() { - let parsed: ByocConfigRegionid = serde_json::from_str("\"ca-central-1\"").unwrap(); - assert_eq!(parsed, ByocConfigRegionid::Ca_central_1); - assert_eq!(parsed.to_string(), "ca-central-1"); -} - -#[test] -fn click_pipe_schema_discovery_response_round_trip() { - let json = r#"{ - "fields": [ - {"name": "user_id", "type": "Int64", "optional": false}, - {"name": "event", "type": "String", "optional": true} - ] - }"#; - let resp: ClickPipeSchemaDiscoveryResponse = serde_json::from_str(json).unwrap(); - let fields = resp.fields.clone().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[0].optional, Some(false)); - assert_eq!(fields[1].optional, Some(true)); - - let v = serde_json::to_value(&resp).unwrap(); - assert_eq!(v["fields"][0]["name"], "user_id"); - assert_eq!(v["fields"][0]["type"], "Int64"); - assert_eq!(v["fields"][1]["optional"], true); -} - -#[test] -fn click_pipe_schema_discovery_request_kafka_source() { - let req = ClickPipeSchemaDiscoveryRequest { - source: ClickPipeSchemaDiscoverySource { - kafka: Some(ClickPipePostKafkaSource::default()), - kinesis: None, - object_storage: None, - pubsub: None, - }, - }; - let v = serde_json::to_value(&req).unwrap(); - assert!(v["source"]["kafka"].is_object()); - assert!(v["source"].get("kinesis").is_none()); - assert!(v["source"].get("objectStorage").is_none()); - assert!(v["source"].get("pubsub").is_none()); -} - -// A Kafka broker with no authentication has no spec enum value; absence of the -// `authentication` key is the wire representation, so `None` must be omitted -// rather than serialized as a mechanism or as `null`. -#[test] -fn click_pipe_post_kafka_source_omits_absent_authentication() { - let no_auth = ClickPipePostKafkaSource { - brokers: "broker1:9092".to_string(), - topics: "events".to_string(), - authentication: None, - credentials: serde_json::Value::Null, - ..Default::default() - }; - let v = serde_json::to_value(&no_auth).unwrap(); - assert!( - v.get("authentication").is_none(), - "absent authentication must be omitted, got {v}" - ); - assert_eq!(v["brokers"], "broker1:9092"); - - let plain = ClickPipePostKafkaSource { - authentication: Some(ClickPipePostKafkaSourceAuthentication::PLAIN), - credentials: serde_json::json!({"username": "u", "password": "p"}), - ..Default::default() - }; - let v = serde_json::to_value(&plain).unwrap(); - assert_eq!(v["authentication"], "PLAIN"); - assert_eq!(v["credentials"]["username"], "u"); -} - -// `kafka_read_committed` is only supported for Kafka pipes, so a settings PUT -// for any other source must leave the key off the wire entirely — not send -// `false` and not send `null`. -#[test] -fn click_pipe_settings_put_request_omits_absent_kafka_read_committed() { - let non_kafka = ClickPipeSettingsPutRequest { - object_storage_max_file_count: Some(200), - kafka_read_committed: None, - ..Default::default() - }; - let v = serde_json::to_value(&non_kafka).unwrap(); - assert!( - v.get("kafka_read_committed").is_none(), - "absent kafka_read_committed must be omitted, got {v}" - ); - assert_eq!( - v, - serde_json::json!({ "object_storage_max_file_count": 200 }) - ); - - let kafka = ClickPipeSettingsPutRequest { - streaming_max_insert_wait_ms: Some(1000), - kafka_read_committed: Some(true), - ..Default::default() - }; - let v = serde_json::to_value(&kafka).unwrap(); - assert_eq!(v["kafka_read_committed"], true); - assert_eq!(v["streaming_max_insert_wait_ms"], 1000); -} - -#[test] -fn click_pipe_schema_discovery_request_supports_new_sources() { - let object_storage = ClickPipeSchemaDiscoveryRequest { - source: ClickPipeSchemaDiscoverySource { - object_storage: Some(ClickPipePostObjectStorageSource::default()), - ..Default::default() - }, - }; - let pubsub = ClickPipeSchemaDiscoveryRequest { - source: ClickPipeSchemaDiscoverySource { - pubsub: Some(ClickPipePostPubSubSource::default()), - ..Default::default() - }, - }; - - assert!(serde_json::to_value(object_storage).unwrap()["source"]["objectStorage"].is_object()); - assert!(serde_json::to_value(pubsub).unwrap()["source"]["pubsub"].is_object()); -} - -#[test] -fn click_pipe_schema_discovery_request_default_omits_sources() { - let v = serde_json::to_value(ClickPipeSchemaDiscoveryRequest::default()).unwrap(); - assert!(v["source"].get("kafka").is_none()); - assert!(v["source"].get("kinesis").is_none()); -} - -#[test] -fn click_pipe_schema_discovery_field_nullable_optional() { - let json = r#"{"name": "col", "type": "Nullable(String)", "optional": null}"#; - let field: ClickPipeSchemaDiscoveryField = serde_json::from_str(json).unwrap(); - assert_eq!(field.optional, None); - let v = serde_json::to_value(&field).unwrap(); - assert!(v.get("optional").is_none()); -} - -#[test] -fn mysql_source_server_id_optional() { - let json = r#"{"host": "h", "port": 3306, "settings": {"replicationMode": "gtid"}, "tableMappings": [], "serverId": 4242}"#; - let src: ClickPipeMySQLSource = serde_json::from_str(json).unwrap(); - assert_eq!(src.server_id, Some(4242)); - - let json = r#"{"host": "h", "port": 3306, "settings": {"replicationMode": "gtid"}, "tableMappings": []}"#; - let src: ClickPipeMySQLSource = serde_json::from_str(json).unwrap(); - assert_eq!(src.server_id, None); - - let v = serde_json::to_value(ClickPipeMySQLSource { - server_id: Some(99), - ..Default::default() - }) - .unwrap(); - assert_eq!(v["serverId"], 99); - let v = serde_json::to_value(ClickPipeMySQLSource::default()).unwrap(); - assert!(v.get("serverId").is_none()); -} - -#[test] -fn mysql_patch_source_server_id_nullable() { - let json = r#"{"serverId": null}"#; - let src: ClickPipePatchMySQLSource = serde_json::from_str(json).unwrap(); - assert_eq!(src.server_id, None); - - let json = r#"{"serverId": 100}"#; - let src: ClickPipePatchMySQLSource = serde_json::from_str(json).unwrap(); - assert_eq!(src.server_id, Some(100)); -} - -#[test] -fn clickstack_alert_num_consecutive_windows_present_and_absent() { - let json = r#"{ - "id": "alert-1", - "name": "High CPU", - "numConsecutiveWindows": 3 - }"#; - let alert: ClickStackAlertResponse = serde_json::from_str(json).unwrap(); - assert_eq!(alert.num_consecutive_windows, Some(3)); - let v = serde_json::to_value(&alert).unwrap(); - assert_eq!(v["numConsecutiveWindows"], 3); - - // Absent -> None -> dropped by skip_serializing_if. - let alert = ClickStackAlertResponse::default(); - assert_eq!(alert.num_consecutive_windows, None); - let v = serde_json::to_value(&alert).unwrap(); - assert!(v.get("numConsecutiveWindows").is_none()); -} - -#[test] -fn clickstack_create_alert_num_consecutive_windows_round_trip() { - let v = serde_json::to_value(ClickStackCreateAlertRequest { - num_consecutive_windows: Some(5), - ..Default::default() - }) - .unwrap(); - assert_eq!(v["numConsecutiveWindows"], 5); - let v = serde_json::to_value(ClickStackCreateAlertRequest::default()).unwrap(); - assert!(v.get("numConsecutiveWindows").is_none()); -} - -#[test] -fn clickstack_update_alert_num_consecutive_windows_round_trip() { - let v = serde_json::to_value(ClickStackUpdateAlertRequest { - num_consecutive_windows: Some(7), - ..Default::default() - }) - .unwrap(); - assert_eq!(v["numConsecutiveWindows"], 7); - let v = serde_json::to_value(ClickStackUpdateAlertRequest::default()).unwrap(); - assert!(v.get("numConsecutiveWindows").is_none()); -} - -#[test] -fn clickstack_filter_applies_to_source_ids_round_trip() { - let json = r#"{ - "expression": "level = 'error'", - "id": "f-1", - "name": "errors", - "sourceId": "src-1", - "type": "QUERY_EXPRESSION", - "appliesToSourceIds": ["src-1", "src-2"] - }"#; - let filter: ClickStackFilter = serde_json::from_str(json).unwrap(); - let ClickStackFilter::ClickStackQueryExpressionFilter(filter) = filter else { - panic!("expected the query-expression variant, got {filter:?}"); - }; - assert_eq!( - filter.applies_to_source_ids, - Some(vec!["src-1".to_string(), "src-2".to_string()]) - ); - let v = serde_json::to_value(&filter).unwrap(); - assert_eq!(v["appliesToSourceIds"][0], "src-1"); - assert_eq!(v["appliesToSourceIds"][1], "src-2"); - - // Absent -> None -> dropped. - let filter = ClickStackQueryExpressionFilter::default(); - assert_eq!(filter.applies_to_source_ids, None); - let v = serde_json::to_value(&filter).unwrap(); - assert!(v.get("appliesToSourceIds").is_none()); -} - -#[test] -fn clickstack_filter_input_applies_to_source_ids_round_trip() { - let json = r#"{ - "expression": "level = 'error'", - "name": "errors", - "sourceId": "src-1", - "type": "QUERY_EXPRESSION", - "appliesToSourceIds": ["src-9"] - }"#; - let filter: ClickStackFilterInput = serde_json::from_str(json).unwrap(); - let ClickStackFilterInput::ClickStackQueryExpressionFilterInput(filter) = filter else { - panic!("expected the query-expression variant, got {filter:?}"); - }; - assert_eq!( - filter.applies_to_source_ids, - Some(vec!["src-9".to_string()]) - ); - let v = serde_json::to_value(&filter).unwrap(); - assert_eq!(v["appliesToSourceIds"][0], "src-9"); - - let v = serde_json::to_value(ClickStackQueryExpressionFilterInput::default()).unwrap(); - assert!(v.get("appliesToSourceIds").is_none()); -} - -#[test] -fn clickpipe_postgres_table_mapping_partition_by_expr_round_trip() { - // `partitionByExpr` is required and non-nullable, so the request variant - // types it as `String` and always sends it. - let mapping = ClickPipePostgresPipeTableMapping { - partition_by_expr: "toYYYYMM(created_at)".to_string(), - ..Default::default() - }; - let v = serde_json::to_value(&mapping).unwrap(); - assert_eq!(v["partitionByExpr"], "toYYYYMM(created_at)"); - - // The response variant types it as `Option`: present, dropped, and - // explicitly `null` all deserialize. - let mapping: ClickPipePostgresPipeTableMappingResponse = - serde_json::from_str(r#"{"partitionByExpr": "toYYYYMM(created_at)"}"#).unwrap(); - assert_eq!( - mapping.partition_by_expr.as_deref(), - Some("toYYYYMM(created_at)") - ); - let mapping: ClickPipePostgresPipeTableMappingResponse = - serde_json::from_str(r#"{"partitionByExpr": null}"#).unwrap(); - assert_eq!(mapping.partition_by_expr, None); - let mapping: ClickPipePostgresPipeTableMappingResponse = serde_json::from_str("{}").unwrap(); - assert_eq!(mapping.partition_by_expr, None); -} - -#[test] -fn clickpipe_response_tolerates_dropped_and_null_fields() { - // The whole ClickPipe response tree is all-`Option`, so a dropped key and - // an explicit `null` both land as `None` — at the top level and inside the - // nested source/destination/scaling/settings shapes. - let dropped: ClickPipe = serde_json::from_str("{}").unwrap(); - let nulled: ClickPipe = serde_json::from_str( - r#"{"id":null,"serviceId":null,"name":null,"state":null,"createdAt":null, - "updatedAt":null,"scaling":null,"settings":null,"source":null, - "destination":null,"fieldMappings":null}"#, - ) - .unwrap(); - assert_eq!(dropped, ClickPipe::default()); - assert_eq!(nulled, ClickPipe::default()); - // `null` on a list field is the residual case the previous - // `#[serde(default)]` policy still failed on. - assert_eq!(nulled.field_mappings, None); - - let nested: ClickPipe = serde_json::from_str( - r#"{"source":{"postgres":{"host":"pg.example","settings":null, - "tableMappings":null}}, - "destination":{"columns":null,"tableDefinition":{"engine":null, - "sortingKey":null}}, - "scaling":{},"settings":{}}"#, - ) - .unwrap(); - let postgres = nested - .source - .and_then(|source| source.postgres) - .expect("postgres source should populate"); - assert_eq!(postgres.host.as_deref(), Some("pg.example")); - assert_eq!(postgres.settings, None); - assert_eq!(postgres.table_mappings, None); - let destination = nested.destination.expect("destination should populate"); - assert_eq!(destination.columns, None); - assert_eq!( - destination.table_definition, - Some(ClickPipeDestinationTableDefinitionResponse::default()) - ); - assert_eq!(nested.scaling, Some(ClickPipeScalingResponse::default())); - assert_eq!(nested.settings, Some(ClickPipeSettingsResponse::default())); -} - -#[test] -fn clickpipe_response_omits_absent_fields_when_serialized() { - // Absence stays absent in `--json` output, including inside the response - // variants of the shared nested pipe schemas. - let pipe = ClickPipe { - name: Some("my-pipe".to_string()), - scaling: Some(ClickPipeScalingResponse { - replicas: Some(2), - ..Default::default() - }), - field_mappings: Some(vec![ClickPipeFieldMappingResponse { - source_field: Some("id".to_string()), - destination_field: None, - }]), - destination: Some(ClickPipeDestination { - table: Some("events".to_string()), - table_definition: Some(ClickPipeDestinationTableDefinitionResponse { - engine: Some(ClickPipeDestinationTableEngineResponse { - r#type: Some(ClickPipeDestinationTableEngineType::MergeTree), - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&pipe).unwrap(), - serde_json::json!({ - "name": "my-pipe", - "scaling": { "replicas": 2 }, - "fieldMappings": [{ "sourceField": "id" }], - "destination": { - "table": "events", - "tableDefinition": { "engine": { "type": "MergeTree" } }, - }, - }) - ); -} - -#[test] -fn shared_clickpipe_nested_types_stay_strict_on_the_request_side() { - // The pipe settings, table mappings, destination shapes, field mappings and - // scaling blocks are sent as well as returned, so each splits: the request - // variant keeps the schema's required fields as `T`, and only the response - // variant is all-`Option`. - let mapping = ClickPipeFieldMapping { - source_field: "id".to_string(), - destination_field: "row_id".to_string(), - }; - assert_eq!( - serde_json::to_value(&mapping).unwrap(), - serde_json::json!({ "sourceField": "id", "destinationField": "row_id" }) - ); - // A request payload missing a required field is rejected, not defaulted. - assert!(serde_json::from_str::("{}").is_err()); - assert!(serde_json::from_str::("{}").is_err()); - assert!(serde_json::from_str::("{}").is_err()); - assert!(serde_json::from_str::("{}").is_err()); - // The response variants accept the same payload. - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickPipeFieldMappingResponse::default() - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickPipeDestinationColumnResponse::default() - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickPipeScalingResponse::default() - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickPipePostgresPipeTableMappingResponse::default() - ); - // Source-conditional create values stay absent unless the caller supplies - // them, while explicit false remains distinguishable from omission. - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickPipeSettings::default() - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickPipePostSource::default() - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickPipeSettingsPutRequest::default() - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickPipeSettingsResponse::default() - ); -} - -#[test] -fn clickpipe_create_optional_controls_omit_absence_and_preserve_false() { - let source = ClickPipePostSource::default(); - let settings = ClickPipeSettings::default(); - assert_eq!( - serde_json::to_value(&source).unwrap(), - serde_json::json!({}) - ); - assert_eq!( - serde_json::to_value(&settings).unwrap(), - serde_json::json!({}) - ); - - let source = ClickPipePostSource { - validate_samples: Some(false), - ..Default::default() - }; - let settings = ClickPipeSettings { - kafka_read_committed: Some(false), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&source).unwrap(), - serde_json::json!({ "validateSamples": false }) - ); - assert_eq!( - serde_json::to_value(&settings).unwrap(), - serde_json::json!({ "kafka_read_committed": false }) - ); -} - -#[test] -fn clickpipe_patch_remove_table_mapping_partition_by_expr_round_trip() { - let json = r#"{"partitionByExpr": "toDate(ts)"}"#; - let mapping: ClickPipePatchPostgresPipeRemoveTableMapping = serde_json::from_str(json).unwrap(); - assert_eq!(mapping.partition_by_expr.as_deref(), Some("toDate(ts)")); - let v = serde_json::to_value(&mapping).unwrap(); - assert_eq!(v["partitionByExpr"], "toDate(ts)"); - - let v = serde_json::to_value(ClickPipePatchPostgresPipeRemoveTableMapping::default()).unwrap(); - assert!(v.get("partitionByExpr").is_none()); -} - -#[test] -fn activity_type_new_wire_values_deserialize_to_typed_variants() { - let cases = [ - ( - "organization_member_update_roles", - ActivityType::Organization_member_update_roles, - ), - ( - "organization_saml_connection_create", - ActivityType::Organization_saml_connection_create, - ), - ( - "organization_saml_connection_update", - ActivityType::Organization_saml_connection_update, - ), - ( - "service_update_snapshot_configuration", - ActivityType::Service_update_snapshot_configuration, - ), - ("backup_bucket_create", ActivityType::Backup_bucket_create), - ("role_update", ActivityType::Role_update), - ("service_mcp_enabled", ActivityType::Service_mcp_enabled), - ("udf_create", ActivityType::Udf_create), - ]; - for (wire, expected) in cases { - let parsed: ActivityType = serde_json::from_str(&format!("\"{wire}\"")).unwrap(); - assert_eq!(parsed, expected, "{wire} should not be Unknown"); - assert_eq!(parsed.to_string(), wire); - assert_eq!(serde_json::to_value(&parsed).unwrap(), wire); - } -} - -#[test] -fn clickstack_alert_response_state_pending() { - let parsed: ClickStackAlertResponseState = serde_json::from_str("\"PENDING\"").unwrap(); - assert_eq!(parsed, ClickStackAlertResponseState::PENDING); - assert_eq!(parsed.to_string(), "PENDING"); - assert_eq!(serde_json::to_value(&parsed).unwrap(), "PENDING"); -} - -#[test] -fn clickstack_number_tile_color_condition_numeric_variant() { - // The manual `operator`-keyed dispatch routes `gt`/`gte`/`lt`/`lte` to the - // numeric variant. - let json = r#"{"operator": "gt", "value": 100, "color": "chart-red"}"#; - let cond: ClickStackNumberTileColorCondition = serde_json::from_str(json).unwrap(); - match cond { - ClickStackNumberTileColorCondition::ClickStackNumericColorCondition(c) => { - assert_eq!(c.operator, ClickStackNumericColorConditionOperator::Gt); - assert_eq!(c.value, 100.0); - assert_eq!(c.color, ClickStackChartColor::Chart_red); - } - other => panic!("expected numeric variant, got {other}"), - } -} - -#[test] -fn clickstack_number_tile_color_condition_between_variant() { - // The manual dispatch routes the `between` operator, with its inclusive - // [min, max] array value, to the between variant. - let json = r#"{"operator": "between", "value": [100, 500], "color": "chart-warning"}"#; - let cond: ClickStackNumberTileColorCondition = serde_json::from_str(json).unwrap(); - match cond { - ClickStackNumberTileColorCondition::ClickStackBetweenColorCondition(c) => { - assert_eq!(c.operator, ClickStackBetweenColorConditionOperator::Between); - assert_eq!(c.value, vec![100.0, 500.0]); - assert_eq!(c.color, ClickStackChartColor::Chart_warning); - } - other => panic!("expected between variant, got {other}"), - } -} - -#[test] -fn clickstack_number_tile_color_condition_equality_string_value() { - // The manual dispatch routes `eq` to the equality variant, whose value - // accepts strings as well as numbers. - let json = - r#"{"operator": "eq", "value": "healthy", "color": "chart-success", "label": "Healthy"}"#; - let cond: ClickStackNumberTileColorCondition = serde_json::from_str(json).unwrap(); - match cond { - ClickStackNumberTileColorCondition::ClickStackEqualityColorCondition(c) => { - assert_eq!(c.operator, ClickStackEqualityColorConditionOperator::Eq); - assert_eq!(c.value, serde_json::json!("healthy")); - assert_eq!(c.color, ClickStackChartColor::Chart_success); - assert_eq!(c.label.as_deref(), Some("Healthy")); - } - other => panic!("expected equality variant, got {other}"), - } -} - -#[test] -fn clickstack_number_tile_color_condition_equality_numeric_value() { - // A numeric-valued `eq` is structurally identical to a numeric condition; - // only the `operator`-keyed dispatch routes it to the equality variant - // rather than the numeric one. - let json = r#"{"operator": "eq", "value": 42, "color": "chart-error"}"#; - let cond: ClickStackNumberTileColorCondition = serde_json::from_str(json).unwrap(); - match cond { - ClickStackNumberTileColorCondition::ClickStackEqualityColorCondition(c) => { - assert_eq!(c.operator, ClickStackEqualityColorConditionOperator::Eq); - assert_eq!(c.value.as_f64(), Some(42.0)); - assert_eq!(c.color, ClickStackChartColor::Chart_error); - } - other => panic!("expected equality variant, got {other}"), - } -} - -#[test] -fn clickstack_number_tile_color_condition_dispatches_every_operator() { - // Exhaustive guard over the `operator`-keyed dispatch: every wire value in the - // discriminated_union! invocation must route to its concrete variant, never the - // Unknown catch-all. A typo in any operator literal in the macro ("gte"/"lt"/ - // "lte"/"neq"/...) would silently misroute a valid payload to Unknown, and this - // table would fail on that operator. The seven values are cross-checked against - // the enum members of the three constituent schemas in the OpenAPI snapshot. - #[derive(Debug, PartialEq)] - enum Variant { - Numeric, - Between, - Equality, - } - - let cases: &[(&str, serde_json::Value, Variant)] = &[ - ("gt", serde_json::json!(100), Variant::Numeric), - ("gte", serde_json::json!(100), Variant::Numeric), - ("lt", serde_json::json!(100), Variant::Numeric), - ("lte", serde_json::json!(100), Variant::Numeric), - ("between", serde_json::json!([100, 500]), Variant::Between), - ("eq", serde_json::json!(42), Variant::Equality), - ("neq", serde_json::json!("healthy"), Variant::Equality), - ]; - - for (operator, value, expected) in cases { - let json = serde_json::json!({ - "operator": operator, - "value": value, - "color": "chart-red", - }); - let cond: ClickStackNumberTileColorCondition = serde_json::from_value(json) - .unwrap_or_else(|e| panic!("operator {operator:?} failed to deserialize: {e}")); - match (&cond, expected) { - ( - ClickStackNumberTileColorCondition::ClickStackNumericColorCondition(c), - Variant::Numeric, - ) => assert_eq!(c.operator.to_string(), *operator), - ( - ClickStackNumberTileColorCondition::ClickStackBetweenColorCondition(c), - Variant::Between, - ) => assert_eq!(c.operator.to_string(), *operator), - ( - ClickStackNumberTileColorCondition::ClickStackEqualityColorCondition(c), - Variant::Equality, - ) => assert_eq!(c.operator.to_string(), *operator), - (other, _) => { - panic!("operator {operator:?} expected {expected:?} variant, got {other}") - } - } - assert!( - !matches!(cond, ClickStackNumberTileColorCondition::Unknown(_)), - "operator {operator:?} misrouted to the Unknown catch-all" - ); - } -} - -#[test] -fn clickstack_number_tile_color_condition_unknown_operator_round_trip() { - // An unrecognized `operator` discriminator falls back to the Unknown - // variant, which now stores the raw JSON object and round-trips faithfully. - let json = r#"{"operator":"contains","value":"warn","color":"chart-red"}"#; - let cond: ClickStackNumberTileColorCondition = serde_json::from_str(json).unwrap(); - match &cond { - ClickStackNumberTileColorCondition::Unknown(v) => { - assert_eq!(v["operator"], "contains"); - assert_eq!(v["value"], "warn"); - } - other => panic!("expected unknown variant, got {other}"), - } - let expected: serde_json::Value = serde_json::from_str(json).unwrap(); - assert_eq!(serde_json::to_value(&cond).unwrap(), expected); -} - -#[test] -fn clickstack_tile_config_categorical_bar_builder_variant() { - // `displayType: "bar"` must dispatch to the categorical bar variant, not the - // stacked bar variant (whose discriminator is "stacked_bar"). - let json = r#"{ - "displayType": "bar", - "sourceId": "src-1", - "select": [{"aggFn": "count"}] - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackCategoricalBarChartConfig( - ClickStackCategoricalBarChartConfig::ClickStackCategoricalBarBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, "src-1"); - assert_eq!( - b.display_type, - ClickStackCategoricalBarBuilderChartConfigDisplaytype::Bar - ); - assert_eq!(b.select.len(), 1); - } - other => panic!("expected categorical bar builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_categorical_bar_raw_sql_variant() { - // The Raw SQL categorical bar (configType "sql") also dispatches to the - // categorical bar variant rather than the stacked bar Raw SQL variant. - let json = r#"{ - "displayType": "bar", - "configType": "sql", - "connectionId": "conn-1", - "sqlTemplate": "SELECT count() FROM t GROUP BY service" - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackCategoricalBarChartConfig( - ClickStackCategoricalBarChartConfig::ClickStackCategoricalBarRawSqlChartConfig(r), - ) => { - assert_eq!(r.connection_id, "conn-1"); - assert_eq!(r.sql_template, "SELECT count() FROM t GROUP BY service"); - assert_eq!( - r.display_type, - ClickStackCategoricalBarRawSqlChartConfigDisplaytype::Bar - ); - } - other => panic!("expected categorical bar raw sql variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_stacked_bar_not_categorical_bar() { - // The manual `displayType`-keyed dispatch routes "stacked_bar" to the - // stacked-bar variant, never to the structurally identical categorical - // bar variant (whose displayType is "bar"). - let json = r#"{ - "displayType": "stacked_bar", - "sourceId": "src-1", - "select": [{"aggFn": "count"}] - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - assert!( - !matches!( - cfg, - ClickStackTileConfig::ClickStackCategoricalBarChartConfig(_) - ), - "stacked_bar must not dispatch to the categorical bar variant, got {cfg}" - ); -} - -#[test] -fn clickstack_tile_config_event_patterns_variant() { - // `displayType: "event_patterns"` dispatches to the event-patterns variant. - let json = r#"{ - "displayType": "event_patterns", - "sourceId": "src-9", - "where": "level:error", - "whereLanguage": "lucene" - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackEventPatternsChartConfig(e) => { - assert_eq!(e.source_id, "src-9"); - assert_eq!( - e.display_type, - ClickStackEventPatternsChartConfigDisplaytype::Event_patterns - ); - assert_eq!(e.r#where.as_deref(), Some("level:error")); - assert_eq!( - e.where_language, - Some(ClickStackEventPatternsChartConfigWherelanguage::Lucene) - ); - } - other => panic!("expected event patterns variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_line_variant() { - // `displayType: "line"` must dispatch to the line variant with a typed - // display_type, not be swallowed as an Unknown displayType by another - // structurally-identical builder variant. - let json = r#"{ - "displayType": "line", - "sourceId": "src-1", - "select": [{"aggFn": "count"}] - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackLineChartConfig( - ClickStackLineChartConfig::ClickStackLineBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, "src-1"); - assert_eq!( - b.display_type, - ClickStackLineBuilderChartConfigDisplaytype::Line - ); - } - other => panic!("expected line builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_stacked_bar_builder_variant() { - // `displayType: "stacked_bar"` must dispatch to the (stacked) bar variant - // with a typed display_type. - let json = r#"{ - "displayType": "stacked_bar", - "sourceId": "src-1", - "select": [{"aggFn": "count"}] - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackBarChartConfig( - ClickStackBarChartConfig::ClickStackBarBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, "src-1"); - assert_eq!( - b.display_type, - ClickStackBarBuilderChartConfigDisplaytype::Stacked_bar - ); - } - other => panic!("expected stacked bar builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_table_variant() { - // `displayType: "table"` must dispatch to the table variant with a typed - // display_type (the legacy misdispatch parsed it as the first-listed - // variant with an Unknown display_type). - let json = r#"{ - "displayType": "table", - "sourceId": "src-1", - "select": [{"aggFn": "count"}] - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackTableChartConfig( - ClickStackTableChartConfig::ClickStackTableBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, "src-1"); - assert_eq!( - b.display_type, - ClickStackTableBuilderChartConfigDisplaytype::Table - ); - } - other => panic!("expected table builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_number_variant() { - // `displayType: "number"` must dispatch to the number variant with a typed - // display_type. - let json = r#"{ - "displayType": "number", - "sourceId": "src-1", - "select": [{"aggFn": "count"}] - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackNumberChartConfig( - ClickStackNumberChartConfig::ClickStackNumberBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, "src-1"); - assert_eq!( - b.display_type, - ClickStackNumberBuilderChartConfigDisplaytype::Number - ); - } - other => panic!("expected number builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_pie_variant() { - // `displayType: "pie"` must dispatch to the pie variant with a typed - // display_type. - let json = r#"{ - "displayType": "pie", - "sourceId": "src-1", - "select": [{"aggFn": "count"}] - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackPieChartConfig( - ClickStackPieChartConfig::ClickStackPieBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, "src-1"); - assert_eq!( - b.display_type, - ClickStackPieBuilderChartConfigDisplaytype::Pie - ); - } - other => panic!("expected pie builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_search_variant() { - // `displayType: "search"` must dispatch to the search variant with a typed - // display_type. - let json = r#"{ - "displayType": "search", - "sourceId": "src-1", - "select": "*", - "whereLanguage": "lucene" - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackSearchChartConfig(s) => { - assert_eq!(s.source_id, "src-1"); - assert_eq!(s.select, "*"); - assert_eq!( - s.display_type, - ClickStackSearchChartConfigDisplaytype::Search - ); - } - other => panic!("expected search variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_markdown_variant() { - // `displayType: "markdown"` must dispatch to the markdown variant with a - // typed display_type rather than being absorbed by an earlier variant. - let json = r#"{ - "displayType": "markdown", - "markdown": "hello world" - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackMarkdownChartConfig(m) => { - assert_eq!(m.markdown.as_deref(), Some("hello world")); - assert_eq!( - m.display_type, - ClickStackMarkdownChartConfigDisplaytype::Markdown - ); - } - other => panic!("expected markdown variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_heatmap_variant() { - // `displayType: "heatmap"` is the only discriminator that reaches the - // ClickStackHeatmapChartConfig arm; the heatmap-specific `select` shape with - // `valueExpression` is then parsed by that variant, not used to select it. - let json = r#"{ - "displayType": "heatmap", - "sourceId": "src-1", - "select": [{"valueExpression": "latency_ms"}] - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackHeatmapChartConfig( - ClickStackHeatmapChartConfig::ClickStackHeatmapDistributionChartConfig(h), - ) => { - assert_eq!(h.source_id, "src-1"); - assert_eq!( - h.display_type, - ClickStackHeatmapDistributionChartConfigDisplaytype::Heatmap - ); - assert_eq!(h.heatmap_mode, None); - assert_eq!(h.select.len(), 1); - assert_eq!(h.select[0].value_expression, "latency_ms"); - } - other => panic!("expected heatmap variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_unknown_display_type_round_trips() { - // An unrecognized `displayType` falls to the Unknown catch-all, which now - // stores the raw object and round-trips it faithfully. - let json = r#"{"displayType":"sankey","sourceId":"src-1"}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { - matches!(cfg, ClickStackTileConfig::Unknown(_)) - }); -} - -#[test] -fn clickstack_tile_config_response_line_novel_shape_dispatches_to_builder() { - // A known `displayType` carrying no `configType` dispatches to the builder - // variant: the novel member is ignored and the builder's all-`Option` - // response fields surface `None` instead of failing the response. - let json = r#"{"displayType":"line","somethingNew":true}"#; - let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfigResponse::ClickStackLineChartConfig( - ClickStackLineChartConfigResponse::ClickStackLineBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, None); - assert_eq!(b.select, None); - } - other => panic!("expected line builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_response_line_minimal_body_dispatches_to_builder() { - // The bare discriminator with no `configType` key at all: key absence is the - // builder discriminator, and every other builder response field is absent. - let json = r#"{"displayType":"line"}"#; - let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfigResponse::ClickStackLineChartConfig( - ClickStackLineChartConfigResponse::ClickStackLineBuilderChartConfig(b), - ) => { - assert_eq!( - b, - ClickStackLineBuilderChartConfigResponse { - display_type: Some(ClickStackLineBuilderChartConfigDisplaytype::Line), - ..ClickStackLineBuilderChartConfigResponse::default() - } - ); - } - other => panic!("expected line builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_response_non_string_config_type_dispatches_to_builder() { - // A non-string `configType` is deliberately conflated with an absent one: - // both dispatch to the builder variant rather than to Unknown. - let json = r#"{"displayType":"line","configType":123,"sourceId":"src-1"}"#; - let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfigResponse::ClickStackLineChartConfig( - ClickStackLineChartConfigResponse::ClickStackLineBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id.as_deref(), Some("src-1")); - } - other => panic!("expected line builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_line_unrecognized_config_type_falls_to_sub_union_unknown() { - // An unrecognized *string* `configType` reaches the line sub-union's - // Unknown(Value); it round-trips losslessly. - let json = r#"{"displayType":"line","configType":"future"}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { - matches!( - cfg, - ClickStackTileConfig::ClickStackLineChartConfig(ClickStackLineChartConfig::Unknown(_)) - ) - }); -} - -#[test] -fn clickstack_tile_config_raw_sql_body_without_config_type_falls_to_sub_union_unknown() { - // `configType` is spec-required on the Raw SQL configs, so a server that - // stops sending it must not silently retype the tile: the builder variant is - // total and would otherwise absorb the body and drop `connectionId` and - // `sqlTemplate`. The `unless` guard routes it to Unknown, which keeps the - // payload verbatim. - let json = r#"{"displayType":"line","connectionId":"conn-1","sqlTemplate":"SELECT 1"}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { - matches!( - cfg, - ClickStackTileConfig::ClickStackLineChartConfig(ClickStackLineChartConfig::Unknown(_)) - ) - }); - - // Either guard key on its own disqualifies the builder variant, and the - // guard is wired on every chart-config sub-union, not just the line one. - let json = r#"{"displayType":"number","connectionId":"conn-1"}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { - matches!( - cfg, - ClickStackTileConfig::ClickStackNumberChartConfig( - ClickStackNumberChartConfig::Unknown(_) - ) - ) - }); -} - -#[test] -fn clickstack_tile_config_changed_field_shape_falls_to_sub_union_unknown() { - // Field-level tolerance only covers a field the API stops sending. A field - // whose *shape* changes cannot deserialize into the dispatched variant, so - // the union hands the payload to its lossless Unknown catch-all rather than - // failing the whole dashboard response. - let builder = r#"{"displayType":"line","sourceId":123}"#; - assert_unknown_variant_round_trips(builder, |cfg: &ClickStackTileConfig| { - matches!( - cfg, - ClickStackTileConfig::ClickStackLineChartConfig(ClickStackLineChartConfig::Unknown(_)) - ) - }); - - let raw_sql = r#"{"displayType":"line","configType":"sql","sqlTemplate":123}"#; - assert_unknown_variant_round_trips(raw_sql, |cfg: &ClickStackTileConfig| { - matches!( - cfg, - ClickStackTileConfig::ClickStackLineChartConfig(ClickStackLineChartConfig::Unknown(_)) - ) - }); -} - -#[test] -fn clickstack_tile_config_line_raw_sql_variant() { - // The Raw SQL line config (configType "sql") dispatches to the line - // sub-union's Raw SQL variant, not its builder or Unknown variant. - let json = r#"{ - "displayType": "line", - "configType": "sql", - "connectionId": "conn-1", - "sqlTemplate": "SELECT count() FROM t GROUP BY toStartOfMinute(ts)" - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackLineChartConfig( - ClickStackLineChartConfig::ClickStackLineRawSqlChartConfig(r), - ) => { - assert_eq!(r.connection_id, "conn-1"); - assert_eq!( - r.sql_template, - "SELECT count() FROM t GROUP BY toStartOfMinute(ts)" - ); - assert_eq!( - r.display_type, - ClickStackLineRawSqlChartConfigDisplaytype::Line - ); - } - other => panic!("expected line raw sql variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_stacked_bar_raw_sql_variant() { - // The Raw SQL (stacked) bar config (configType "sql") dispatches to the bar - // sub-union's Raw SQL variant, not its builder or Unknown variant. - let json = r#"{ - "displayType": "stacked_bar", - "configType": "sql", - "connectionId": "conn-1", - "sqlTemplate": "SELECT count() FROM t GROUP BY service" - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackBarChartConfig( - ClickStackBarChartConfig::ClickStackBarRawSqlChartConfig(r), - ) => { - assert_eq!(r.connection_id, "conn-1"); - assert_eq!(r.sql_template, "SELECT count() FROM t GROUP BY service"); - assert_eq!( - r.display_type, - ClickStackBarRawSqlChartConfigDisplaytype::Stacked_bar - ); - } - other => panic!("expected stacked bar raw sql variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_table_raw_sql_variant() { - // The Raw SQL table config (configType "sql") dispatches to the table - // sub-union's Raw SQL variant, not its builder or Unknown variant. - let json = r#"{ - "displayType": "table", - "configType": "sql", - "connectionId": "conn-1", - "sqlTemplate": "SELECT service, count() FROM t GROUP BY service" - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackTableChartConfig( - ClickStackTableChartConfig::ClickStackTableRawSqlChartConfig(r), - ) => { - assert_eq!(r.connection_id, "conn-1"); - assert_eq!( - r.sql_template, - "SELECT service, count() FROM t GROUP BY service" - ); - assert_eq!( - r.display_type, - ClickStackTableRawSqlChartConfigDisplaytype::Table - ); - } - other => panic!("expected table raw sql variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_number_raw_sql_variant() { - // The Raw SQL number config (configType "sql") dispatches to the number - // sub-union's Raw SQL variant, not its builder or Unknown variant. - let json = r#"{ - "displayType": "number", - "configType": "sql", - "connectionId": "conn-1", - "sqlTemplate": "SELECT count() FROM t" - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackNumberChartConfig( - ClickStackNumberChartConfig::ClickStackNumberRawSqlChartConfig(r), - ) => { - assert_eq!(r.connection_id, "conn-1"); - assert_eq!(r.sql_template, "SELECT count() FROM t"); - assert_eq!( - r.display_type, - ClickStackNumberRawSqlChartConfigDisplaytype::Number - ); - } - other => panic!("expected number raw sql variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_pie_raw_sql_variant() { - // The Raw SQL pie config (configType "sql") dispatches to the pie - // sub-union's Raw SQL variant, not its builder or Unknown variant. - let json = r#"{ - "displayType": "pie", - "configType": "sql", - "connectionId": "conn-1", - "sqlTemplate": "SELECT service, count() FROM t GROUP BY service" - }"#; - let cfg: ClickStackTileConfig = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfig::ClickStackPieChartConfig( - ClickStackPieChartConfig::ClickStackPieRawSqlChartConfig(r), - ) => { - assert_eq!(r.connection_id, "conn-1"); - assert_eq!( - r.sql_template, - "SELECT service, count() FROM t GROUP BY service" - ); - assert_eq!( - r.display_type, - ClickStackPieRawSqlChartConfigDisplaytype::Pie - ); - } - other => panic!("expected pie raw sql variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_response_categorical_bar_novel_shape_dispatches_to_builder() { - // A "bar" body with no `configType` dispatches to the categorical bar builder - // variant, whose all-`Option` response fields surface `None` rather than - // failing the response. - let json = r#"{"displayType":"bar","somethingNew":true}"#; - let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfigResponse::ClickStackCategoricalBarChartConfig( - ClickStackCategoricalBarChartConfigResponse::ClickStackCategoricalBarBuilderChartConfig( - b, - ), - ) => { - assert_eq!(b.source_id, None); - assert_eq!(b.select, None); - } - other => panic!("expected categorical bar builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_categorical_bar_unrecognized_config_type_falls_to_sub_union_unknown() { - // An unrecognized *string* `configType` reaches the categorical bar - // sub-union's Unknown(Value); it round-trips losslessly. - let json = r#"{"displayType":"bar","configType":"future"}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { - matches!( - cfg, - ClickStackTileConfig::ClickStackCategoricalBarChartConfig( - ClickStackCategoricalBarChartConfig::Unknown(_) - ) - ) - }); -} - -#[test] -fn clickstack_tile_config_response_stacked_bar_novel_shape_dispatches_to_builder() { - // A "stacked_bar" body with no `configType` dispatches to the bar builder - // variant, whose all-`Option` response fields surface `None` rather than - // failing the response. - let json = r#"{"displayType":"stacked_bar","somethingNew":true}"#; - let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfigResponse::ClickStackBarChartConfig( - ClickStackBarChartConfigResponse::ClickStackBarBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, None); - assert_eq!(b.select, None); - } - other => panic!("expected bar builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_stacked_bar_unrecognized_config_type_falls_to_sub_union_unknown() { - // An unrecognized *string* `configType` reaches the bar sub-union's - // Unknown(Value); it round-trips losslessly. - let json = r#"{"displayType":"stacked_bar","configType":"future"}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { - matches!( - cfg, - ClickStackTileConfig::ClickStackBarChartConfig(ClickStackBarChartConfig::Unknown(_)) - ) - }); -} - -#[test] -fn clickstack_tile_config_response_table_novel_shape_dispatches_to_builder() { - // A "table" body with no `configType` dispatches to the table builder - // variant, whose all-`Option` response fields surface `None` rather than - // failing the response. - let json = r#"{"displayType":"table","somethingNew":true}"#; - let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfigResponse::ClickStackTableChartConfig( - ClickStackTableChartConfigResponse::ClickStackTableBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, None); - assert_eq!(b.select, None); - } - other => panic!("expected table builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_table_unrecognized_config_type_falls_to_sub_union_unknown() { - // An unrecognized *string* `configType` reaches the table sub-union's - // Unknown(Value); it round-trips losslessly. - let json = r#"{"displayType":"table","configType":"future"}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { - matches!( - cfg, - ClickStackTileConfig::ClickStackTableChartConfig(ClickStackTableChartConfig::Unknown( - _ - )) - ) - }); -} - -#[test] -fn clickstack_tile_config_response_number_novel_shape_dispatches_to_builder() { - // A "number" body with no `configType` dispatches to the number builder - // variant, whose all-`Option` response fields surface `None` rather than - // failing the response. - let json = r#"{"displayType":"number","somethingNew":true}"#; - let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfigResponse::ClickStackNumberChartConfig( - ClickStackNumberChartConfigResponse::ClickStackNumberBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, None); - assert_eq!(b.select, None); - } - other => panic!("expected number builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_number_unrecognized_config_type_falls_to_sub_union_unknown() { - // An unrecognized *string* `configType` reaches the number sub-union's - // Unknown(Value); it round-trips losslessly. - let json = r#"{"displayType":"number","configType":"future"}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { - matches!( - cfg, - ClickStackTileConfig::ClickStackNumberChartConfig( - ClickStackNumberChartConfig::Unknown(_) - ) - ) - }); -} - -#[test] -fn clickstack_tile_config_response_pie_novel_shape_dispatches_to_builder() { - // A "pie" body with no `configType` dispatches to the pie builder - // variant, whose all-`Option` response fields surface `None` rather than - // failing the response. - let json = r#"{"displayType":"pie","somethingNew":true}"#; - let cfg: ClickStackTileConfigResponse = serde_json::from_str(json).unwrap(); - match cfg { - ClickStackTileConfigResponse::ClickStackPieChartConfig( - ClickStackPieChartConfigResponse::ClickStackPieBuilderChartConfig(b), - ) => { - assert_eq!(b.source_id, None); - assert_eq!(b.select, None); - } - other => panic!("expected pie builder variant, got {other}"), - } -} - -#[test] -fn clickstack_tile_config_pie_unrecognized_config_type_falls_to_sub_union_unknown() { - // An unrecognized *string* `configType` reaches the pie sub-union's - // Unknown(Value); it round-trips losslessly. - let json = r#"{"displayType":"pie","configType":"future"}"#; - assert_unknown_variant_round_trips(json, |cfg: &ClickStackTileConfig| { - matches!( - cfg, - ClickStackTileConfig::ClickStackPieChartConfig(ClickStackPieChartConfig::Unknown(_)) - ) - }); -} - -#[test] -fn clickstack_chart_color_known_and_unknown_round_trip() { - // A known palette token maps to its typed variant and back. - let known: ClickStackChartColor = serde_json::from_str("\"chart-light-blue\"").unwrap(); - assert_eq!(known, ClickStackChartColor::Chart_light_blue); - assert_eq!(known.to_string(), "chart-light-blue"); - assert_eq!(serde_json::to_value(&known).unwrap(), "chart-light-blue"); - - // An unrecognized token round-trips through the Unknown(String) catch-all. - let unknown: ClickStackChartColor = serde_json::from_str("\"chart-teal\"").unwrap(); - assert_eq!( - unknown, - ClickStackChartColor::Unknown("chart-teal".to_string()) - ); - assert_eq!(unknown.to_string(), "chart-teal"); - assert_eq!(serde_json::to_value(&unknown).unwrap(), "chart-teal"); -} - -#[test] -fn clickstack_background_chart_round_trip() { - let json = r#"{"type": "area", "color": "chart-blue"}"#; - let bg: ClickStackBackgroundChart = serde_json::from_str(json).unwrap(); - assert_eq!(bg.r#type, ClickStackBackgroundChartType::Area); - assert_eq!(bg.color, Some(ClickStackChartColor::Chart_blue)); - let v = serde_json::to_value(&bg).unwrap(); - assert_eq!(v["type"], "area"); - assert_eq!(v["color"], "chart-blue"); - - // color is optional and dropped when absent. - let json = r#"{"type": "line"}"#; - let bg: ClickStackBackgroundChart = serde_json::from_str(json).unwrap(); - assert_eq!(bg.r#type, ClickStackBackgroundChartType::Line); - assert_eq!(bg.color, None); - let v = serde_json::to_value(&bg).unwrap(); - assert!(v.get("color").is_none()); -} - -#[test] -fn clickstack_number_builder_chart_config_color_fields_round_trip() { - let json = r#"{ - "displayType": "number", - "sourceId": "src-1", - "select": [{"aggFn": "count"}], - "color": "chart-blue", - "colorRules": [ - {"operator": "gt", "value": 100, "color": "chart-red"} - ], - "backgroundChart": {"type": "line", "color": "chart-green"} - }"#; - let cfg: ClickStackNumberBuilderChartConfig = serde_json::from_str(json).unwrap(); - assert_eq!(cfg.color, Some(ClickStackChartColor::Chart_blue)); - let rules = cfg.color_rules.as_ref().unwrap(); - assert_eq!(rules.len(), 1); - match &rules[0] { - ClickStackNumberTileColorCondition::ClickStackNumericColorCondition(c) => { - assert_eq!(c.operator, ClickStackNumericColorConditionOperator::Gt); - } - other => panic!("expected numeric rule, got {other}"), - } - let bg = cfg.background_chart.as_ref().unwrap(); - assert_eq!(bg.r#type, ClickStackBackgroundChartType::Line); - - let v = serde_json::to_value(&cfg).unwrap(); - assert_eq!(v["color"], "chart-blue"); - assert_eq!(v["colorRules"][0]["operator"], "gt"); - assert_eq!(v["backgroundChart"]["type"], "line"); - - // The three new fields are optional and dropped when absent. - let v = serde_json::to_value(ClickStackNumberBuilderChartConfig::default()).unwrap(); - assert!(v.get("color").is_none()); - assert!(v.get("colorRules").is_none()); - assert!(v.get("backgroundChart").is_none()); -} - -#[test] -fn clickstack_number_raw_sql_chart_config_color_round_trip() { - let json = r#"{ - "configType": "sql", - "connectionId": "conn-1", - "displayType": "number", - "sqlTemplate": "SELECT count() FROM t", - "color": "chart-purple" - }"#; - let cfg: ClickStackNumberRawSqlChartConfig = serde_json::from_str(json).unwrap(); - assert_eq!(cfg.color, Some(ClickStackChartColor::Chart_purple)); - let v = serde_json::to_value(&cfg).unwrap(); - assert_eq!(v["color"], "chart-purple"); - - let v = serde_json::to_value(ClickStackNumberRawSqlChartConfig::default()).unwrap(); - assert!(v.get("color").is_none()); -} - -#[test] -fn deserialize_clickstack_connection_with_null_prefix() { - let json = r#"{ - "id": "507f1f77bcf86cd799439012", - "name": "Production ClickHouse", - "host": "https://clickhouse.example.com:8443", - "username": "default", - "hyperdxSettingPrefix": null, - "isPrometheusEndpoint": false, - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-06-15T10:30:00.000Z" - }"#; - let conn: ClickStackConnection = serde_json::from_str(json).unwrap(); - assert_eq!(conn.id.as_deref(), Some("507f1f77bcf86cd799439012")); - assert_eq!(conn.name.as_deref(), Some("Production ClickHouse")); - assert_eq!( - conn.host.as_deref(), - Some("https://clickhouse.example.com:8443") - ); - assert_eq!(conn.username.as_deref(), Some("default")); - assert_eq!(conn.hyperdx_setting_prefix, None); - assert_eq!(conn.is_prometheus_endpoint, Some(false)); - assert!(conn.created_at.is_some()); - assert!(conn.updated_at.is_some()); -} - -#[test] -fn serialize_clickstack_create_connection_request_omits_none() { - let req = ClickStackCreateConnectionRequest { - name: "Production ClickHouse".to_string(), - host: "https://clickhouse.example.com:8443".to_string(), - username: "default".to_string(), - password: Some("my-secret-password".to_string()), - ..Default::default() - }; - let v = serde_json::to_value(&req).unwrap(); - assert_eq!(v["name"], "Production ClickHouse"); - assert_eq!(v["host"], "https://clickhouse.example.com:8443"); - assert_eq!(v["username"], "default"); - assert_eq!(v["password"], "my-secret-password"); - assert!( - v.get("hyperdxSettingPrefix").is_none(), - "hyperdxSettingPrefix must be omitted when None" - ); - assert!( - v.get("isPrometheusEndpoint").is_none(), - "isPrometheusEndpoint must be omitted when None" - ); -} - -/// A ClickHouse-integration permission built from every property of -/// `ClickStackClickHouseCASLPermission` and its row-policy conditions. -fn clickhouse_casl_permission_json() -> serde_json::Value { - serde_json::json!({ - "action": "read", - "subject": "Source", - "fields": ["Body", "ServiceName"], - "inverted": false, - "integration": "clickhouse", - "conditions": { - "connection": "507f1f77bcf86cd799439011", - "from": {"databaseName": "default", "tableName": "otel_logs"}, - "where": "ServiceName = 'checkout'", - "whereLanguage": "sql" - } - }) -} - -/// A MongoDB-integration permission built from every property of -/// `ClickStackMongoDBCASLPermission`, with both condition value shapes. -fn mongodb_casl_permission_json() -> serde_json::Value { - serde_json::json!({ - "action": "manage", - "subject": "Dashboard", - "fields": ["name"], - "inverted": true, - "integration": "mongodb", - "conditions": { - "team": "team-1", - "name": {"$regex": "^prod-", "$options": "i"} - } - }) -} - -#[test] -fn clickstack_casl_permission_request_routes_each_shape_to_its_variant() { - let permission: ClickStackCASLPermission = - serde_json::from_value(clickhouse_casl_permission_json()).unwrap(); - let ClickStackCASLPermission::ClickStackClickHouseCASLPermission(clickhouse) = &permission - else { - panic!("expected the ClickHouse variant, got {permission:?}"); - }; - assert_eq!(clickhouse.action, ClickStackCASLPermissionAction::Read); - assert_eq!(clickhouse.subject, ClickStackCASLPermissionSubject::Source); - assert_eq!( - clickhouse.integration, - ClickStackClickHouseCASLPermissionIntegration::Clickhouse - ); - assert_eq!( - clickhouse.fields.as_deref(), - Some(&["Body".to_string(), "ServiceName".to_string()][..]) - ); - assert_eq!(clickhouse.inverted, Some(false)); - let conditions = clickhouse.conditions.as_ref().unwrap(); - assert_eq!(conditions.connection, "507f1f77bcf86cd799439011"); - assert_eq!(conditions.from.database_name, "default"); - assert_eq!(conditions.from.table_name, "otel_logs"); - assert_eq!(conditions.r#where, "ServiceName = 'checkout'"); - assert_eq!( - conditions.where_language, - ClickStackClickHouseCASLPermissionConditionsWherelanguage::Sql - ); - - let permission: ClickStackCASLPermission = - serde_json::from_value(mongodb_casl_permission_json()).unwrap(); - let ClickStackCASLPermission::ClickStackMongoDBCASLPermission(mongodb) = &permission else { - panic!("expected the MongoDB variant, got {permission:?}"); - }; - assert_eq!(mongodb.action, ClickStackCASLPermissionAction::Manage); - assert_eq!(mongodb.subject, ClickStackCASLPermissionSubject::Dashboard); - assert_eq!( - mongodb.integration, - ClickStackMongoDBCASLPermissionIntegration::Mongodb - ); - assert_eq!(mongodb.inverted, Some(true)); - let conditions = mongodb.conditions.as_ref().unwrap(); - assert_eq!( - conditions["team"], - ClickStackMongoDBCASLPermissionConditionValue::String("team-1".to_string()) - ); - assert_eq!( - conditions["name"], - ClickStackMongoDBCASLPermissionConditionValue::Regex( - ClickStackMongoDBCASLPermissionRegexCondition { - regex: "^prod-".to_string(), - options: Some("i".to_string()), - } - ) - ); -} - -#[test] -fn clickstack_casl_permission_response_routes_each_shape_to_its_variant() { - let permission: ClickStackCASLPermissionResponse = - serde_json::from_value(clickhouse_casl_permission_json()).unwrap(); - let ClickStackCASLPermissionResponse::ClickStackClickHouseCASLPermission(clickhouse) = - &permission - else { - panic!("expected the ClickHouse variant, got {permission:?}"); - }; - assert_eq!( - clickhouse.action, - Some(ClickStackCASLPermissionAction::Read) - ); - let conditions = clickhouse.conditions.as_ref().unwrap(); - assert_eq!( - conditions.connection.as_deref(), - Some("507f1f77bcf86cd799439011") - ); - assert_eq!( - conditions.from.as_ref().unwrap().table_name.as_deref(), - Some("otel_logs") - ); - assert_eq!( - conditions.where_language, - Some(ClickStackClickHouseCASLPermissionConditionsWherelanguage::Sql) - ); - - let permission: ClickStackCASLPermissionResponse = - serde_json::from_value(mongodb_casl_permission_json()).unwrap(); - let ClickStackCASLPermissionResponse::ClickStackMongoDBCASLPermission(mongodb) = &permission - else { - panic!("expected the MongoDB variant, got {permission:?}"); - }; - assert_eq!( - mongodb.integration, - Some(ClickStackMongoDBCASLPermissionIntegration::Mongodb) - ); - // Response condition values stay raw JSON, whatever shape they take. - let conditions = mongodb.conditions.as_ref().unwrap(); - assert_eq!(conditions["team"], "team-1"); - assert_eq!(conditions["name"]["$regex"], "^prod-"); -} - -#[test] -fn clickstack_casl_permission_dispatch_ignores_the_payload_shape() { - // Dispatch reads `integration` alone. Every response arm is all-`Option`, - // so shape-based `untagged` matching would accept any object in the first - // arm; a ClickHouse row policy must not become a MongoDB permission, and a - // MongoDB query must not become a ClickHouse one. - let mut clickhouse_conditions_as_mongodb = clickhouse_casl_permission_json(); - clickhouse_conditions_as_mongodb["integration"] = "mongodb".into(); - let permission: ClickStackCASLPermissionResponse = - serde_json::from_value(clickhouse_conditions_as_mongodb.clone()).unwrap(); - assert!( - matches!( - permission, - ClickStackCASLPermissionResponse::ClickStackMongoDBCASLPermission(_) - ), - "{permission:?}" - ); - - let mut mongodb_conditions_as_clickhouse = mongodb_casl_permission_json(); - mongodb_conditions_as_clickhouse["integration"] = "clickhouse".into(); - // `conditions.name` is an object where the ClickHouse shape has no such - // key, and the ClickHouse `from` is absent: the request variant cannot be - // built, so the request union keeps the payload verbatim in `Unknown`. - let permission: ClickStackCASLPermission = - serde_json::from_value(mongodb_conditions_as_clickhouse.clone()).unwrap(); - assert_eq!( - permission, - ClickStackCASLPermission::Unknown(mongodb_conditions_as_clickhouse) - ); - - // No integration, or one this crate does not know, never guesses a variant. - for integration in [None, Some("postgres")] { - let mut payload = mongodb_casl_permission_json(); - match integration { - Some(integration) => payload["integration"] = integration.into(), - None => { - payload.as_object_mut().unwrap().remove("integration"); - } - } - let request: ClickStackCASLPermission = serde_json::from_value(payload.clone()).unwrap(); - assert_eq!(request, ClickStackCASLPermission::Unknown(payload.clone())); - let response: ClickStackCASLPermissionResponse = - serde_json::from_value(payload.clone()).unwrap(); - assert_eq!( - response, - ClickStackCASLPermissionResponse::Unknown(payload.clone()) - ); - assert_eq!(serde_json::to_value(&response).unwrap(), payload); - } -} - -#[test] -fn clickstack_casl_permission_round_trips_in_both_positions() { - for payload in [ - clickhouse_casl_permission_json(), - mongodb_casl_permission_json(), - ] { - let request: ClickStackCASLPermission = serde_json::from_value(payload.clone()).unwrap(); - assert!(!matches!(request, ClickStackCASLPermission::Unknown(_))); - assert_eq!(serde_json::to_value(&request).unwrap(), payload); - let back: ClickStackCASLPermission = - serde_json::from_value(serde_json::to_value(&request).unwrap()).unwrap(); - assert_eq!(back, request); - - let response: ClickStackCASLPermissionResponse = - serde_json::from_value(payload.clone()).unwrap(); - assert!(!matches!( - response, - ClickStackCASLPermissionResponse::Unknown(_) - )); - assert_eq!(serde_json::to_value(&response).unwrap(), payload); - } -} - -#[test] -fn clickstack_casl_permission_variant_defaults_round_trip_to_their_own_variant() { - // Each variant's `integration` defaults to its own wire value, so a - // default-built request variant still carries its discriminator. - let clickhouse = ClickStackCASLPermission::ClickStackClickHouseCASLPermission( - ClickStackClickHouseCASLPermission::default(), - ); - let mongodb = ClickStackCASLPermission::ClickStackMongoDBCASLPermission( - ClickStackMongoDBCASLPermission::default(), - ); - for permission in [clickhouse, mongodb] { - let json = serde_json::to_value(&permission).unwrap(); - assert_eq!( - serde_json::from_value::(json).unwrap(), - permission - ); - } -} - -#[test] -fn clickstack_casl_permission_try_from_response_converts_every_integration() { - for payload in [ - clickhouse_casl_permission_json(), - mongodb_casl_permission_json(), - ] { - let response: ClickStackCASLPermissionResponse = - serde_json::from_value(payload.clone()).unwrap(); - let request = ClickStackCASLPermission::try_from(response).unwrap(); - assert!( - !matches!(request, ClickStackCASLPermission::Unknown(_)), - "{request:?}" - ); - assert_eq!(serde_json::to_value(&request).unwrap(), payload); - } - - // The variant conversions round-trip on their own too. - let response: ClickStackClickHouseCASLPermissionResponse = - serde_json::from_value(clickhouse_casl_permission_json()).unwrap(); - let request = ClickStackClickHouseCASLPermission::try_from(response).unwrap(); - assert_eq!( - request, - serde_json::from_value::( - clickhouse_casl_permission_json() - ) - .unwrap() - ); - let response: ClickStackMongoDBCASLPermissionResponse = - serde_json::from_value(mongodb_casl_permission_json()).unwrap(); - let request = ClickStackMongoDBCASLPermission::try_from(response).unwrap(); - assert_eq!( - request, - serde_json::from_value::(mongodb_casl_permission_json()) - .unwrap() - ); -} - -#[test] -fn clickstack_casl_permission_try_from_response_names_every_missing_required_field() { - for (json, expected) in [ - ( - r#"{"integration":"clickhouse"}"#, - &["action", "subject"][..], - ), - ( - r#"{"integration":"mongodb","action":"read"}"#, - &["subject"][..], - ), - // Nested ClickHouse conditions name their own wire fields, unprefixed. - ( - r#"{"integration":"clickhouse","action":"read","subject":"Source","conditions":{}}"#, - &["connection", "from", "where", "whereLanguage"][..], - ), - ( - r#"{"integration":"clickhouse","action":"read","subject":"Source","conditions": - {"connection":"c","where":"1","whereLanguage":"sql","from":{"databaseName":"d"}}}"#, - &["tableName"][..], - ), - ] { - let response: ClickStackCASLPermissionResponse = serde_json::from_str(json).unwrap(); - assert!( - !matches!(response, ClickStackCASLPermissionResponse::Unknown(_)), - "{json}" - ); - let error = ClickStackCASLPermission::try_from(response).unwrap_err(); - assert_eq!(error.fields(), expected, "{json}"); - } - - // The variant conversions also require the discriminator itself. - let error = ClickStackClickHouseCASLPermission::try_from( - ClickStackClickHouseCASLPermissionResponse::default(), - ) - .unwrap_err(); - assert_eq!(error.fields(), ["action", "integration", "subject"]); - let error = ClickStackMongoDBCASLPermission::try_from( - ClickStackMongoDBCASLPermissionResponse::default(), - ) - .unwrap_err(); - assert_eq!(error.fields(), ["action", "integration", "subject"]); - - // An unmodelled integration passes through losslessly. - let json = r#"{"integration":"postgres","action":"read"}"#; - let response: ClickStackCASLPermissionResponse = serde_json::from_str(json).unwrap(); - let request = ClickStackCASLPermission::try_from(response).unwrap(); - assert_eq!( - serde_json::to_value(&request).unwrap(), - serde_json::from_str::(json).unwrap() - ); -} - -#[test] -fn clickstack_mongodb_casl_conditions_without_a_request_shape_are_never_dropped() { - // A condition value that is neither a string nor a `$regex` object. - let mut payload = mongodb_casl_permission_json(); - payload["conditions"]["count"] = serde_json::json!({"$gt": 3}); - - // The variant conversion names `conditions` instead of dropping the value, - // which would widen the permission. - let response: ClickStackMongoDBCASLPermissionResponse = - serde_json::from_value(payload.clone()).unwrap(); - let error = ClickStackMongoDBCASLPermission::try_from(response).unwrap_err(); - assert_eq!(error.fields(), ["conditions"]); - - // The union conversion writes the permission back verbatim, exactly as the - // request union deserializes the same body. - let response: ClickStackCASLPermissionResponse = - serde_json::from_value(payload.clone()).unwrap(); - let request = ClickStackCASLPermission::try_from(response).unwrap(); - assert_eq!(request, ClickStackCASLPermission::Unknown(payload.clone())); - assert_eq!( - serde_json::from_value::(payload.clone()).unwrap(), - request - ); - - // A missing required field is still reported, alongside `conditions`. - payload.as_object_mut().unwrap().remove("action"); - let response: ClickStackCASLPermissionResponse = serde_json::from_value(payload).unwrap(); - let error = ClickStackCASLPermission::try_from(response).unwrap_err(); - assert_eq!(error.fields(), ["action", "conditions"]); -} - -#[test] -fn clickstack_casl_permission_variants_stay_strict_on_the_request_side() { - // Each required field of the request variants is `T`: dropping one fails - // the variant (and so routes the union payload to `Unknown`). - for (payload, required) in [ - (clickhouse_casl_permission_json(), "action"), - (clickhouse_casl_permission_json(), "subject"), - (clickhouse_casl_permission_json(), "integration"), - (mongodb_casl_permission_json(), "action"), - (mongodb_casl_permission_json(), "subject"), - (mongodb_casl_permission_json(), "integration"), - ] { - let mut payload = payload; - payload.as_object_mut().unwrap().remove(required); - let integration = payload.get("integration").cloned(); - let accepted = if integration.is_none() { - serde_json::from_value::(payload.clone()).is_ok() - || serde_json::from_value::(payload.clone()) - .is_ok() - } else if integration == Some("clickhouse".into()) { - serde_json::from_value::(payload.clone()).is_ok() - } else { - serde_json::from_value::(payload.clone()).is_ok() - }; - assert!(!accepted, "accepted a permission without `{required}`"); - assert!(matches!( - serde_json::from_value::(payload).unwrap(), - ClickStackCASLPermission::Unknown(_) - )); - } - for required in ["connection", "from", "where", "whereLanguage"] { - let mut conditions = clickhouse_casl_permission_json()["conditions"].clone(); - conditions.as_object_mut().unwrap().remove(required); - assert!( - serde_json::from_value::(conditions) - .is_err(), - "accepted ClickHouse conditions without `{required}`" - ); - } - for required in ["databaseName", "tableName"] { - let mut from = clickhouse_casl_permission_json()["conditions"]["from"].clone(); - from.as_object_mut().unwrap().remove(required); - assert!( - serde_json::from_value::(from) - .is_err(), - "accepted a ClickHouse `from` without `{required}`" - ); - } - // A MongoDB condition value is a string or a `$regex` object, nothing else. - for value in [ - serde_json::json!({"$options": "i"}), - serde_json::json!(3), - serde_json::json!(["a"]), - ] { - assert!( - serde_json::from_value::( - serde_json::json!({ "name": value }) - ) - .is_err(), - "accepted MongoDB condition value {value}" - ); - } -} - -#[test] -fn clickstack_casl_permission_responses_tolerate_missing_and_null_fields() { - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickStackClickHouseCASLPermissionResponse::default() - ); - assert_eq!( - serde_json::from_str::( - r#"{"action":null,"subject":null,"fields":null,"inverted":null,"integration":null,"conditions":null}"# - ) - .unwrap(), - ClickStackClickHouseCASLPermissionResponse::default() - ); - assert_eq!( - serde_json::from_str::( - r#"{"connection":null,"from":null,"where":null,"whereLanguage":null}"# - ) - .unwrap(), - ClickStackClickHouseCASLPermissionConditionsResponse::default() - ); - assert_eq!( - serde_json::from_str::( - r#"{"databaseName":null,"tableName":null}"# - ) - .unwrap(), - ClickStackClickHouseCASLPermissionConditionsFromResponse::default() - ); - assert_eq!( - serde_json::from_str::( - r#"{"action":null,"subject":null,"fields":null,"inverted":null,"integration":null,"conditions":null}"# - ) - .unwrap(), - ClickStackMongoDBCASLPermissionResponse::default() - ); - - // A dispatched permission with only its discriminator keeps that variant, - // and serializes without inventing the absent fields. - let permission: ClickStackCASLPermissionResponse = - serde_json::from_str(r#"{"integration":"clickhouse","action":null}"#).unwrap(); - assert_eq!( - permission, - ClickStackCASLPermissionResponse::ClickStackClickHouseCASLPermission( - ClickStackClickHouseCASLPermissionResponse { - integration: Some(ClickStackClickHouseCASLPermissionIntegration::Clickhouse), - ..Default::default() - } - ) - ); - assert_eq!( - serde_json::to_value(&permission).unwrap(), - serde_json::json!({"integration": "clickhouse"}) - ); -} - -#[test] -fn deserialize_clickstack_role_with_both_permission_kinds() { - let json = serde_json::json!({ - "id": "role-1", - "name": "Deploy Bot", - "description": "Manages dashboards via Terraform", - "isPredefined": false, - "permissions": [mongodb_casl_permission_json(), clickhouse_casl_permission_json()] - }); - let role: ClickStackRole = serde_json::from_value(json.clone()).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)); - let permissions = role.permissions.as_deref().expect("permissions present"); - assert!(matches!( - permissions, - [ - ClickStackCASLPermissionResponse::ClickStackMongoDBCASLPermission(_), - ClickStackCASLPermissionResponse::ClickStackClickHouseCASLPermission(_), - ] - )); - - // Optional role fields are omitted when absent, and the role round-trips. - let sparse = ClickStackRole { - id: Some("role-1".to_string()), - ..Default::default() - }; - let v = serde_json::to_value(&sparse).unwrap(); - assert!(v.get("description").is_none()); - assert!(v.get("createdAt").is_none()); - assert!(v.get("permissions").is_none()); - assert_eq!(serde_json::to_value(&role).unwrap(), json); - let back: ClickStackRole = serde_json::from_value(json).unwrap(); - assert_eq!(back, role); -} - -#[test] -fn serialize_clickstack_casl_permission_omits_none() { - let perm = ClickStackCASLPermission::ClickStackMongoDBCASLPermission( - ClickStackMongoDBCASLPermission { - action: ClickStackCASLPermissionAction::Read, - subject: ClickStackCASLPermissionSubject::Dashboard, - ..Default::default() - }, - ); - assert_eq!( - serde_json::to_value(&perm).unwrap(), - serde_json::json!({"action": "read", "subject": "Dashboard", "integration": "mongodb"}) - ); - let perm = ClickStackCASLPermission::ClickStackClickHouseCASLPermission( - ClickStackClickHouseCASLPermission { - action: ClickStackCASLPermissionAction::Read, - subject: ClickStackCASLPermissionSubject::All, - ..Default::default() - }, - ); - assert_eq!( - serde_json::to_value(&perm).unwrap(), - serde_json::json!({"action": "read", "subject": "all", "integration": "clickhouse"}) - ); -} - -#[test] -fn serialize_clickstack_create_role_request_omits_none() { - let req = ClickStackCreateRoleRequest { - name: "Deploy Bot".to_string(), - permissions: vec![serde_json::from_value(mongodb_casl_permission_json()).unwrap()], - ..Default::default() - }; - let v = serde_json::to_value(&req).unwrap(); - assert_eq!(v["name"], "Deploy Bot"); - assert_eq!(v["permissions"][0], mongodb_casl_permission_json()); - assert!(v.get("description").is_none()); -} - -#[test] -fn serialize_clickstack_update_role_request_omits_none() { - let req = ClickStackUpdateRoleRequest { - permissions: vec![serde_json::from_value(clickhouse_casl_permission_json()).unwrap()], - ..Default::default() - }; - let v = serde_json::to_value(&req).unwrap(); - assert_eq!(v["permissions"][0], clickhouse_casl_permission_json()); - assert!(v.get("name").is_none()); - assert!(v.get("description").is_none()); -} - -#[test] -fn deserialize_clickstack_saved_search_full_round_trip() { - let json = r#"{ - "id": "507f1f77bcf86cd799439011", - "name": "Production Errors", - "sourceId": "507f1f77bcf86cd799439012", - "select": "Timestamp, ServiceName, Body", - "where": "SeverityText:ERROR", - "whereLanguage": "lucene", - "orderBy": "Timestamp DESC", - "tags": ["production", "errors"], - "filters": [ - {"type": "sql", "condition": "ServiceName IN ('checkout', 'payments')"} - ], - "teamId": "507f1f77bcf86cd799439013", - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-06-15T10:30:00.000Z" - }"#; - let search: ClickStackSavedSearch = serde_json::from_str(json).unwrap(); - assert_eq!(search.id.as_deref(), Some("507f1f77bcf86cd799439011")); - assert_eq!(search.name.as_deref(), Some("Production Errors")); - assert_eq!( - search.source_id.as_deref(), - Some("507f1f77bcf86cd799439012") - ); - assert_eq!( - search.where_language, - Some(ClickStackSavedSearchWherelanguage::Lucene) - ); - let filters = search.filters.clone().unwrap(); - assert_eq!(filters.len(), 1); - assert_eq!( - filters[0].r#type, - Some(ClickStackSavedSearchFilterType::Sql) - ); - assert_eq!( - filters[0].condition.as_deref(), - Some("ServiceName IN ('checkout', 'payments')") - ); - assert_eq!(search.team_id, Some("507f1f77bcf86cd799439013".to_string())); - - let v = serde_json::to_value(&search).unwrap(); - assert_eq!(v["whereLanguage"], "lucene"); - assert_eq!(v["filters"][0]["type"], "sql"); - assert_eq!(v["orderBy"], "Timestamp DESC"); - assert_eq!(v["tags"][0], "production"); - - let round: ClickStackSavedSearch = serde_json::from_value(v).unwrap(); - assert_eq!(round, search); -} - -#[test] -fn serialize_clickstack_saved_search_input_minimal_omits_optionals() { - let input = ClickStackSavedSearchInput { - name: "Production Errors".to_string(), - source_id: "507f1f77bcf86cd799439012".to_string(), - ..Default::default() - }; - let v = serde_json::to_value(&input).unwrap(); - assert_eq!(v["name"], "Production Errors"); - assert_eq!(v["sourceId"], "507f1f77bcf86cd799439012"); - assert!(v.get("select").is_none()); - assert!(v.get("where").is_none()); - assert!(v.get("whereLanguage").is_none()); - assert!(v.get("orderBy").is_none()); - assert!(v.get("tags").is_none()); - assert!(v.get("filters").is_none()); -} - -#[test] -fn clickstack_webhook_input_headers_and_query_params_round_trip() { - let json = r#"{ - "name": "Production Alerts", - "service": "incidentio", - "url": "https://api.incident.io/v2/alert_events/http/xyz", - "description": "Sends critical alerts", - "body": "{\"alert\": \"{{title}}\"}", - "headers": {"Authorization": "Bearer token", "X-Custom": "value"}, - "queryParams": {"source": "clickstack", "env": "prod"} - }"#; - let input: ClickStackWebhookInput = serde_json::from_str(json).unwrap(); - assert_eq!(input.name, "Production Alerts"); - assert_eq!(input.service, ClickStackWebhookInputService::Incidentio); - assert_eq!( - input.url, - "https://api.incident.io/v2/alert_events/http/xyz" - ); - - let headers = input.headers.clone().unwrap(); - assert_eq!( - headers.get("Authorization"), - Some(&"Bearer token".to_string()) - ); - assert_eq!(headers.get("X-Custom"), Some(&"value".to_string())); - let query_params = input.query_params.clone().unwrap(); - assert_eq!(query_params.get("source"), Some(&"clickstack".to_string())); - assert_eq!(query_params.get("env"), Some(&"prod".to_string())); - - // Maps serialize back as JSON objects, and the enum keeps its wire value. - let v = serde_json::to_value(&input).unwrap(); - assert_eq!(v["service"], "incidentio"); - assert!(v["headers"].is_object()); - assert_eq!(v["headers"]["Authorization"], "Bearer token"); - assert!(v["queryParams"].is_object()); - assert_eq!(v["queryParams"]["source"], "clickstack"); - - let back: ClickStackWebhookInput = serde_json::from_value(v).unwrap(); - assert_eq!(back, input); -} - -#[test] -fn serialize_clickstack_webhook_input_minimal_omits_optionals() { - let input = ClickStackWebhookInput { - name: "Slack Alerts".to_string(), - service: ClickStackWebhookInputService::Slack, - url: "https://hooks.slack.com/services/T/B/X".to_string(), - ..Default::default() - }; - let v = serde_json::to_value(&input).unwrap(); - assert_eq!(v["name"], "Slack Alerts"); - assert_eq!(v["service"], "slack"); - assert_eq!(v["url"], "https://hooks.slack.com/services/T/B/X"); - assert!(v.get("description").is_none()); - assert!(v.get("body").is_none()); - assert!(v.get("headers").is_none()); - assert!(v.get("queryParams").is_none()); -} - -#[test] -fn deserialize_clickstack_webhook_dispatches_slack() { - let json = r#"{ - "id": "webhook-1", - "name": "Slack 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" - }"#; - let w: ClickStackWebhook = serde_json::from_str(json).unwrap(); - match w { - ClickStackWebhook::ClickStackSlackWebhook(s) => { - assert_eq!(s.id.as_deref(), Some("webhook-1")); - assert_eq!(s.name.as_deref(), Some("Slack Alerts")); - } - other => panic!("expected Slack webhook variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_webhook_dispatches_incidentio() { - let json = r#"{ - "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" - }"#; - let w: ClickStackWebhook = serde_json::from_str(json).unwrap(); - match w { - ClickStackWebhook::ClickStackIncidentIOWebhook(i) => { - assert_eq!(i.id.as_deref(), Some("webhook-2")); - assert_eq!(i.name.as_deref(), Some("Incident Alerts")); - } - other => panic!("expected IncidentIO webhook variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_webhook_dispatches_generic_preserves_body() { - let json = r#"{ - "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" - }"#; - let w: ClickStackWebhook = serde_json::from_str(json).unwrap(); - match w { - ClickStackWebhook::ClickStackGenericWebhook(g) => { - assert_eq!(g.id.as_deref(), Some("webhook-3")); - assert_eq!(g.body.as_deref(), Some("{\"text\": \"{{ message }}\"}")); - } - other => panic!("expected Generic webhook variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_webhook_dispatches_slack_api() { - let json = r#"{ - "id": "webhook-4", - "name": "Slack API Alerts", - "service": "slack_api", - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-06-15T10:30:00.000Z" - }"#; - let w: ClickStackWebhook = serde_json::from_str(json).unwrap(); - match w { - ClickStackWebhook::ClickStackSlackAPIWebhook(s) => { - assert_eq!(s.id.as_deref(), Some("webhook-4")); - assert_eq!(s.name.as_deref(), Some("Slack API Alerts")); - } - other => panic!("expected SlackAPI webhook variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_webhook_dispatches_pagerduty_api() { - let json = r#"{ - "id": "webhook-5", - "name": "PagerDuty Alerts", - "service": "pagerduty_api", - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-06-15T10:30:00.000Z" - }"#; - let w: ClickStackWebhook = serde_json::from_str(json).unwrap(); - match w { - ClickStackWebhook::ClickStackPagerDutyAPIWebhook(p) => { - assert_eq!(p.id.as_deref(), Some("webhook-5")); - assert_eq!(p.name.as_deref(), Some("PagerDuty Alerts")); - } - other => panic!("expected PagerDutyAPI webhook variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_webhook_unknown_service_round_trips() { - let json = r#"{ - "id": "webhook-6", - "name": "Future Alerts", - "service": "future_service", - "extraField": "kept", - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-06-15T10:30:00.000Z" - }"#; - assert_unknown_variant_round_trips(json, |w: &ClickStackWebhook| { - matches!(w, ClickStackWebhook::Unknown(_)) - }); -} - -#[test] -fn clickstack_source_expression_fields_missing_or_null_are_none() { - // `orderByExpression` (log, trace), the log-only OpenTelemetry - // `eventNameExpression` and `severityNumberExpression`, - // `resourceAttributesExpression` (session), and the trace-only display, - // sampling and span-link expressions are optional in the spec: absent or `null` lands as `None` - // on the response variants and is omitted from the request variants. - let log = serde_json::json!({ - "kind": "log", - "name": "logs", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "from": {"databaseName": "default", "tableName": "logs"}, - "timestampValueExpression": "ts", - }); - let trace = serde_json::json!({ - "kind": "trace", - "name": "traces", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "durationExpression": "Duration", - "durationPrecision": 9, - "from": {"databaseName": "default", "tableName": "traces"}, - "parentSpanIdExpression": "ParentSpanId", - "spanIdExpression": "SpanId", - "spanKindExpression": "SpanKind", - "spanNameExpression": "SpanName", - "timestampValueExpression": "Timestamp", - "traceIdExpression": "TraceId", - }); - let session = serde_json::json!({ - "kind": "session", - "name": "sessions", - "connection": "conn-1", - "from": {"databaseName": "default", "tableName": "sessions"}, - "traceSourceId": "trace-1", - }); - let with_nulls = |base: &serde_json::Value, keys: &[&str]| { - let mut v = base.clone(); - for key in keys { - v[*key] = serde_json::Value::Null; - } - v - }; - let trace_keys = [ - "displayedTimestampValueExpression", - "orderByExpression", - "sampleRateExpression", - "spanLinksValueExpression", - ]; - - let log_keys = [ - "eventNameExpression", - "orderByExpression", - "severityNumberExpression", - ]; - - for payload in [log.clone(), with_nulls(&log, &log_keys)] { - let response: ClickStackLogSourceResponse = serde_json::from_value(payload).unwrap(); - assert_eq!(response.event_name_expression, None); - assert_eq!(response.order_by_expression, None); - assert_eq!(response.severity_number_expression, None); - let request = ClickStackLogSource::try_from(response).unwrap(); - assert_eq!(serde_json::to_value(&request).unwrap(), log); - } - for payload in [trace.clone(), with_nulls(&trace, &trace_keys)] { - let response: ClickStackTraceSourceResponse = serde_json::from_value(payload).unwrap(); - assert_eq!(response.displayed_timestamp_value_expression, None); - assert_eq!(response.order_by_expression, None); - assert_eq!(response.sample_rate_expression, None); - assert_eq!(response.span_links_value_expression, None); - let request = ClickStackTraceSource::try_from(response).unwrap(); - assert_eq!(serde_json::to_value(&request).unwrap(), trace); - } - for payload in [ - session.clone(), - with_nulls(&session, &["resourceAttributesExpression"]), - ] { - let response: ClickStackSessionSourceResponse = serde_json::from_value(payload).unwrap(); - assert_eq!(response.resource_attributes_expression, None); - let request = ClickStackSessionSource::try_from(response).unwrap(); - assert_eq!(serde_json::to_value(&request).unwrap(), session); - } -} - -#[test] -fn clickstack_source_expression_fields_serialize_with_their_wire_names() { - let log = ClickStackLogSource { - event_name_expression: Some("EventName".to_string()), - order_by_expression: Some("Timestamp DESC".to_string()), - severity_number_expression: Some("SeverityNumber".to_string()), - ..Default::default() - }; - let v = serde_json::to_value(&log).unwrap(); - assert_eq!(v["eventNameExpression"], "EventName"); - assert_eq!(v["orderByExpression"], "Timestamp DESC"); - assert_eq!(v["severityNumberExpression"], "SeverityNumber"); - - let session = ClickStackSessionSource { - resource_attributes_expression: Some("ResourceAttributes".to_string()), - ..Default::default() - }; - let v = serde_json::to_value(&session).unwrap(); - assert_eq!(v["resourceAttributesExpression"], "ResourceAttributes"); - - let trace = ClickStackTraceSource { - displayed_timestamp_value_expression: Some("Timestamp".to_string()), - order_by_expression: Some("Timestamp DESC".to_string()), - sample_rate_expression: Some("SampleRate".to_string()), - span_links_value_expression: Some("Links".to_string()), - ..Default::default() - }; - let v = serde_json::to_value(&trace).unwrap(); - assert_eq!(v["displayedTimestampValueExpression"], "Timestamp"); - assert_eq!(v["orderByExpression"], "Timestamp DESC"); - assert_eq!(v["sampleRateExpression"], "SampleRate"); - assert_eq!(v["spanLinksValueExpression"], "Links"); -} - -#[test] -fn clickstack_log_source_otel_expressions_write_back() { - let wire = serde_json::json!({ - "kind": "log", - "name": "logs", - "connection": "conn-1", - "defaultTableSelectExpression": "*", - "from": {"databaseName": "default", "tableName": "logs"}, - "timestampValueExpression": "Timestamp", - "eventNameExpression": "EventName", - "severityNumberExpression": "SeverityNumber", - "severityTextExpression": "SeverityText", - }); - let response: ClickStackLogSourceResponse = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(response.event_name_expression.as_deref(), Some("EventName")); - assert_eq!( - response.severity_number_expression.as_deref(), - Some("SeverityNumber") - ); - let request = ClickStackLogSource::try_from(response).unwrap(); - assert_eq!(request.event_name_expression.as_deref(), Some("EventName")); - assert_eq!( - request.severity_number_expression.as_deref(), - Some("SeverityNumber") - ); - assert_eq!(serde_json::to_value(&request).unwrap(), wire); -} - -#[test] -fn clickstack_validate_webhook_response_null_fields_are_none() { - let null: ClickStackValidateWebhookResponse = - serde_json::from_str(r#"{"valid": null, "errors": null, "normalized": null}"#).unwrap(); - assert_eq!(null, ClickStackValidateWebhookResponse::default()); - // Absent fields are omitted on serialize, not emitted as `null`. - assert_eq!(serde_json::to_value(&null).unwrap(), serde_json::json!({})); - - let item: ClickStackValidateWebhookResponseErrorsItem = - serde_json::from_str(r#"{"path": null, "message": null}"#).unwrap(); - assert_eq!(item, ClickStackValidateWebhookResponseErrorsItem::default()); - - let resp: ClickStackValidateWebhookResponse = serde_json::from_str( - r#"{"valid": false, "errors": [{"path": "", "message": "Top-level error"}], - "normalized": {"name": "hook"}}"#, - ) - .unwrap(); - assert_eq!(resp.valid, Some(false)); - let errors = resp.errors.as_ref().expect("errors should populate"); - assert_eq!(errors[0].path.as_deref(), Some("")); - assert_eq!(errors[0].message.as_deref(), Some("Top-level error")); - // `normalized` is a free-form Value; its payload is preserved verbatim. - assert_eq!(resp.normalized.as_ref().unwrap()["name"], "hook"); -} - -#[test] -fn clickstack_validate_dashboard_response_round_trip() { - let json = r#"{ - "valid": false, - "errors": [ - {"path": "tiles.0.config", "message": "Required"}, - {"path": "", "message": "Top-level error"} - ], - "normalized": {"name": "My Dashboard", "tiles": [{"id": "t1"}]} - }"#; - let resp: ClickStackValidateDashboardResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.valid, Some(false)); - let errors = resp.errors.as_ref().expect("errors should populate"); - assert_eq!(errors.len(), 2); - assert_eq!(errors[0].path.as_deref(), Some("tiles.0.config")); - assert_eq!(errors[0].message.as_deref(), Some("Required")); - assert_eq!(errors[1].path.as_deref(), Some("")); - - // `normalized` is a free-form Value; its arbitrary payload is preserved. - let normalized = resp.normalized.as_ref().unwrap(); - assert_eq!(normalized["name"], "My Dashboard"); - assert_eq!(normalized["tiles"][0]["id"], "t1"); - - let v = serde_json::to_value(&resp).unwrap(); - assert_eq!(v["valid"], false); - assert_eq!(v["errors"][0]["path"], "tiles.0.config"); - assert_eq!(v["normalized"]["name"], "My Dashboard"); - - let back: ClickStackValidateDashboardResponse = serde_json::from_value(v).unwrap(); - assert_eq!(back, resp); -} - -#[test] -fn clickstack_validate_dashboard_response_valid_null_normalized() { - let json = r#"{"valid": true, "errors": [], "normalized": null}"#; - let resp: ClickStackValidateDashboardResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.valid, Some(true)); - assert_eq!(resp.errors.as_deref(), Some(&[][..])); - assert_eq!(resp.normalized, None); - - // An explicit `null` lands as `None` and, like every absent response - // field, is omitted on the way out rather than re-emitted as `null`. - let v = serde_json::to_value(&resp).unwrap(); - assert!( - v.get("normalized").is_none(), - "absent response fields must be omitted from --json output" - ); -} - -#[test] -fn organization_quota_typed_enums_round_trip() { - let json = r#"{ - "quotaCode": "replicas-per-warehouse", - "name": "Replicas per warehouse", - "description": "Limits each warehouse individually.", - "scope": "warehouse", - "value": 20, - "usage": 3, - "adjustable": true - }"#; - let quota: OrganizationQuota = serde_json::from_str(json).unwrap(); - assert_eq!( - quota.quota_code, - Some(OrganizationQuotaQuotacode::Replicas_per_warehouse) - ); - assert_eq!(quota.scope, Some(OrganizationQuotaScope::Warehouse)); - assert_eq!(quota.value, Some(20)); - assert_eq!(quota.usage, Some(3)); - assert_eq!(quota.adjustable, Some(true)); - - let v = serde_json::to_value("a).unwrap(); - assert_eq!(v["quotaCode"], "replicas-per-warehouse"); - assert_eq!(v["scope"], "warehouse"); - assert_eq!(v["value"], 20); - assert_eq!(v["usage"], 3); - - let back: OrganizationQuota = serde_json::from_value(v).unwrap(); - assert_eq!(back, quota); -} - -#[test] -fn organization_api_key_quota_code_round_trips() { - let parsed: OrganizationQuotaQuotacode = - serde_json::from_str("\"api-keys-per-organization\"").unwrap(); - assert_eq!( - parsed, - OrganizationQuotaQuotacode::Api_keys_per_organization - ); - assert_eq!(parsed.to_string(), "api-keys-per-organization"); - assert_eq!( - serde_json::to_value(parsed).unwrap(), - "api-keys-per-organization" - ); -} - -#[test] -fn new_response_models_tolerate_absent_and_null_fields() { - let balances: ActiveBalances = - serde_json::from_str(r#"{"totalRemainingPrepaidCredits":null,"prepaidBalances":null}"#) - .unwrap(); - let balance: ActiveBalance = serde_json::from_str( - r#"{"id":null,"remainingPrepaidCredits":null,"totalAmount":null,"amountSpent":null,"startDate":null,"expirationDate":null}"#, - ) - .unwrap(); - let labels: PrometheusDiscoveryLabels = serde_json::from_str( - r#"{"__scheme__":null,"__metrics_path__":null,"__param_filtered_metrics":null,"clickhouse_org_id":null,"clickhouse_service_id":null,"clickhouse_discovery_service_name":null}"#, - ) - .unwrap(); - let group: PrometheusDiscoveryTargetGroup = - serde_json::from_str(r#"{"targets":null,"labels":null}"#).unwrap(); - let log: PostgresLogEntry = - serde_json::from_str(r#"{"timestamp":null,"severity":null,"body":null}"#).unwrap(); - - assert_eq!(balances, ActiveBalances::default()); - assert_eq!(balance, ActiveBalance::default()); - assert_eq!(labels, PrometheusDiscoveryLabels::default()); - assert_eq!(group, PrometheusDiscoveryTargetGroup::default()); - assert_eq!(log, PostgresLogEntry::default()); - assert_eq!( - serde_json::to_value(balances).unwrap(), - serde_json::json!({}) - ); - assert_eq!(serde_json::to_value(log).unwrap(), serde_json::json!({})); -} - -#[test] -fn organization_quota_usage_optional_omitted() { - let json = r#"{ - "quotaCode": "services-per-organization", - "name": "Services per organization", - "description": "Limits services.", - "scope": "organization", - "value": 20, - "adjustable": false - }"#; - let quota: OrganizationQuota = serde_json::from_str(json).unwrap(); - assert_eq!( - quota.quota_code, - Some(OrganizationQuotaQuotacode::Services_per_organization) - ); - assert_eq!(quota.scope, Some(OrganizationQuotaScope::Organization)); - assert_eq!(quota.usage, None); - - let v = serde_json::to_value("a).unwrap(); - assert!(v.get("usage").is_none(), "usage must be omitted when None"); -} - -#[test] -fn organization_quota_tolerates_explicit_null_fields() { - // `OrganizationQuota` is response-only, so every field is `Option` and an - // explicit `null` lands as `None` exactly like a dropped key — the case the - // superseded `#[serde(default)]` policy never covered. - let quota: OrganizationQuota = serde_json::from_str( - r#"{"quotaCode":null,"name":null,"description":null,"scope":null, - "value":null,"usage":null,"adjustable":null}"#, - ) - .unwrap(); - assert_eq!(quota, OrganizationQuota::default()); - - // Absence is omitted on the way out, never re-emitted as `null`. - assert_eq!( - serde_json::to_value("a).unwrap(), - serde_json::json!({}), - "absent response fields must be omitted from --json output" - ); -} - -#[test] -fn scim_request_models_stay_strict() { - // The spec defines the SCIM schemas but no SCIM path, so the family is in - // neither direction's tree and stays strict (see the comment above - // `ScimEnterpriseManager` in models.rs and - // `scim_models_are_outside_the_response_tree` in spec_coverage_test.rs). - // Required fields are therefore `T` and a dropped one is a hard error. - let err = serde_json::from_str::(r#"{"schemas":[]}"#).unwrap_err(); - assert!( - err.to_string().contains("displayName"), - "unexpected error: {err}" - ); - - // Absent optional fields are still omitted rather than sent as `null`. - let group = ScimGroupPostRequest { - display_name: "Engineering".to_string(), - external_id: None, - id: None, - members: None, - meta: None, - schemas: vec!["urn:ietf:params:scim:schemas:core:2.0:Group".to_string()], - }; - let v = serde_json::to_value(&group).unwrap(); - assert_eq!(v["displayName"], "Engineering"); - assert_eq!( - v["schemas"][0], - "urn:ietf:params:scim:schemas:core:2.0:Group" - ); - assert!(v.get("externalId").is_none()); - assert!(v.get("members").is_none()); - assert!(v.get("id").is_none()); - assert!(v.get("meta").is_none()); -} - -#[test] -fn scim_request_echoed_id_and_meta_round_trip() { - let created = "2026-01-02T03:04:05Z"; - let modified = "2026-01-03T03:04:05Z"; - let patch: ScimPatchOp = serde_json::from_value(serde_json::json!({ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations": [], - "id": "user-1", - "meta": {"resourceType": "User", "created": created, "lastModified": modified} - })) - .unwrap(); - assert_eq!(patch.id.as_deref(), Some("user-1")); - let Some(ScimPatchOpMeta::ScimUserMeta(meta)) = &patch.meta else { - panic!("expected user meta, got {:?}", patch.meta); - }; - assert_eq!(meta.resource_type, "User"); - assert_eq!( - serde_json::to_value(&patch).unwrap()["meta"]["resourceType"], - "User" - ); - - let group_meta = serde_json::json!({ - "resourceType": "Group", "created": created, "lastModified": modified - }); - let parsed: ScimPatchOpMeta = serde_json::from_value(group_meta.clone()).unwrap(); - assert!(matches!(parsed, ScimPatchOpMeta::ScimGroupMeta(_))); - let other = serde_json::json!({"resourceType": "Device"}); - let parsed: ScimPatchOpMeta = serde_json::from_value(other.clone()).unwrap(); - assert_eq!(parsed, ScimPatchOpMeta::Unknown(other)); - - let group: ScimGroupPostRequest = serde_json::from_value(serde_json::json!({ - "schemas": [], "displayName": "Engineering", "id": "group-1", "meta": group_meta - })) - .unwrap(); - assert_eq!(group.id.as_deref(), Some("group-1")); - assert_eq!( - group.meta.map(|meta| meta.resource_type).as_deref(), - Some("Group") - ); - - let user = ScimUserPostRequest { - user_name: "ada".to_string(), - ..Default::default() - }; - let v = serde_json::to_value(&user).unwrap(); - assert!(v.get("id").is_none()); - assert!(v.get("meta").is_none()); - let v = serde_json::to_value(ScimPatchOp::default()).unwrap(); - assert!(v.get("id").is_none()); - assert!(v.get("meta").is_none()); -} - -#[test] -fn organization_quota_quota_code_unknown_catch_all() { - let parsed: OrganizationQuotaQuotacode = - serde_json::from_str("\"queries-per-second\"").unwrap(); - assert_eq!( - parsed, - OrganizationQuotaQuotacode::Unknown("queries-per-second".to_string()) - ); - assert_eq!(parsed.to_string(), "queries-per-second"); - assert_eq!(serde_json::to_value(&parsed).unwrap(), "queries-per-second"); -} - -#[test] -fn deserialize_clickstack_dashboard_chart_series_dispatches_time() { - // The manual `type`-keyed dispatch routes "time" to the time-series variant. - let json = r#"{ - "aggFn": "count", - "groupBy": ["service"], - "sourceId": "src-1", - "type": "time", - "where": "x = 1", - "whereLanguage": "sql" - }"#; - let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); - match &series { - ClickStackDashboardChartSeries::ClickStackTimeChartSeries(s) => { - assert_eq!(s.r#type, ClickStackTimeChartSeriesType::Time); - assert_eq!(s.agg_fn, ClickStackTimeChartSeriesAggfn::Count); - } - other => panic!("expected time variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_dashboard_chart_series_dispatches_table() { - // Regression: Time and Table share every required field (aggFn, groupBy, - // sourceId, type, where, whereLanguage), so before manual discriminator - // dispatch a `type: "table"` payload greedily resolved to the Time variant - // and silently dropped table-only fields like `sortOrder`. The union now - // routes on the `type` discriminator and preserves `sortOrder`. - let json = r#"{ - "aggFn": "count", - "groupBy": ["service"], - "sortOrder": "desc", - "sourceId": "src-2", - "type": "table", - "where": "y = 2", - "whereLanguage": "sql" - }"#; - let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); - match &series { - ClickStackDashboardChartSeries::ClickStackTableChartSeries(s) => { - assert_eq!(s.r#type, ClickStackTableChartSeriesType::Table); - assert_eq!( - s.sort_order, - Some(ClickStackTableChartSeriesSortorder::Desc) - ); - } - other => panic!("expected table variant, got {other}"), - } - // The table-only `sortOrder` survives the round-trip through the union. - let v = serde_json::to_value(&series).unwrap(); - assert_eq!(v["type"], "table"); - assert_eq!(v["sortOrder"], "desc"); -} - -#[test] -fn deserialize_clickstack_dashboard_chart_series_dispatches_number() { - // The manual `type`-keyed dispatch routes "number" to the number-series variant. - let json = r#"{ - "aggFn": "count", - "sourceId": "src-3", - "type": "number", - "where": "z = 3", - "whereLanguage": "sql" - }"#; - let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); - match &series { - ClickStackDashboardChartSeries::ClickStackNumberChartSeries(s) => { - assert_eq!(s.r#type, ClickStackNumberChartSeriesType::Number); - } - other => panic!("expected number variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_dashboard_chart_series_dispatches_search() { - // The manual `type`-keyed dispatch routes "search" to the search-series variant. - let json = r#"{ - "fields": ["message"], - "sourceId": "src-4", - "type": "search", - "where": "w = 4", - "whereLanguage": "sql" - }"#; - let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); - match &series { - ClickStackDashboardChartSeries::ClickStackSearchChartSeries(s) => { - assert_eq!(s.r#type, ClickStackSearchChartSeriesType::Search); - assert_eq!(s.fields, vec!["message".to_string()]); - } - other => panic!("expected search variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_dashboard_chart_series_dispatches_markdown() { - // The manual `type`-keyed dispatch routes "markdown" to the markdown-series variant. - let json = r##"{"content": "# Title", "type": "markdown"}"##; - let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); - match &series { - ClickStackDashboardChartSeries::ClickStackMarkdownChartSeries(s) => { - assert_eq!(s.r#type, ClickStackMarkdownChartSeriesType::Markdown); - assert_eq!(s.content, "# Title"); - } - other => panic!("expected markdown variant, got {other}"), - } -} - -#[test] -fn deserialize_clickstack_dashboard_chart_series_unknown_type_round_trip() { - // An unrecognized `type` discriminator falls back to the Unknown variant, - // which stores the raw JSON so it round-trips faithfully. - let json = r#"{"type":"heatmap","payload":{"nested":[1,2,3]}}"#; - let series: ClickStackDashboardChartSeries = serde_json::from_str(json).unwrap(); - match &series { - ClickStackDashboardChartSeries::Unknown(v) => { - assert_eq!(v["type"], "heatmap"); - assert_eq!(v["payload"]["nested"][2], 3); - } - other => panic!("expected unknown variant, got {other}"), - } - let expected: serde_json::Value = serde_json::from_str(json).unwrap(); - assert_eq!(serde_json::to_value(&series).unwrap(), expected); -} - -#[test] -fn reverse_private_endpoint_create_enums_round_trip_and_match_values() { - // The CLI validates `reverse-private-endpoint create --type` and - // `--msk-authentication` against `VALUES`, so each const must stay equal - // to the wire values its enum actually accepts. - assert_eq!( - CreateReversePrivateEndpointType::VALUES, - &[ - "VPC_ENDPOINT_SERVICE", - "VPC_RESOURCE", - "MSK_MULTI_VPC", - "GCP_PSC_SERVICE_ATTACHMENT", - ] - ); - for value in CreateReversePrivateEndpointType::VALUES { - let parsed: CreateReversePrivateEndpointType = - serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!(parsed, CreateReversePrivateEndpointType::Unknown(_)), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - assert_eq!( - serde_json::to_string(&parsed).unwrap(), - format!(r#""{value}""#) - ); - } - - assert_eq!( - CreateReversePrivateEndpointMskauthentication::VALUES, - &["SASL_IAM", "SASL_SCRAM"] - ); - for value in CreateReversePrivateEndpointMskauthentication::VALUES { - let parsed: CreateReversePrivateEndpointMskauthentication = - serde_json::from_str(&format!(r#""{value}""#)).unwrap(); - assert!( - !matches!( - parsed, - CreateReversePrivateEndpointMskauthentication::Unknown(_) - ), - "{value} fell through to the catch-all" - ); - assert_eq!(parsed.to_string(), *value); - assert_eq!( - serde_json::to_string(&parsed).unwrap(), - format!(r#""{value}""#) - ); - } -} - -/// The endpoint's own model is a response type: a key the API drops and a key -/// it sends as `null` both have to land as `None`, and neither is emitted back. -#[test] -fn reverse_private_endpoint_tolerates_missing_and_null_fields() { - let sparse: ReversePrivateEndpoint = serde_json::from_str(r#"{"description":"warehouse"}"#) - .expect("a response with one key must deserialize"); - assert_eq!(sparse.description.as_deref(), Some("warehouse")); - assert_eq!(sparse.dns_names, None); - assert_eq!(sparse.status, None); - assert_eq!(sparse.id, None); - - let explicit_nulls: ReversePrivateEndpoint = serde_json::from_str( - r#"{"description":null,"dnsNames":null,"status":null,"id":null,"type":null}"#, - ) - .expect("explicit nulls must deserialize"); - assert_eq!(explicit_nulls, ReversePrivateEndpoint::default()); - assert_eq!( - serde_json::to_string(&explicit_nulls).unwrap(), - "{}", - "absent fields must be omitted, never serialized as null" - ); -} - -#[test] -fn new_response_models_preserve_missing_and_null_fields() { - macro_rules! tolerant { - ($model:ty, [$($field:literal),+]) => {{ - let empty: $model = serde_json::from_value(serde_json::json!({})).unwrap(); - let nulls: $model = serde_json::from_value(serde_json::json!({$($field: null),+})).unwrap(); - assert_eq!(empty, nulls); - assert_eq!(serde_json::to_value(empty).unwrap(), serde_json::json!({})); - }}; - } - tolerant!( - CreditBalance, - [ - "id", - "type", - "remainingCredits", - "totalAmount", - "amountSpent", - "startDate", - "expirationDate" - ] - ); - tolerant!(CreditBalances, ["totalRemainingCredits", "balances"]); - tolerant!(ServiceProfile, ["profile", "cpuCores", "memoryGi"]); - tolerant!( - ClickPipeBigQueryServiceAccountSource, - [ - "snapshotStagingPath", - "settings", - "tableMappings", - "authentication", - "projectId" - ] - ); - tolerant!( - ClickPipeBigQueryWorkloadIdentitySource, - [ - "snapshotStagingPath", - "settings", - "tableMappings", - "authentication", - "projectId" - ] - ); - tolerant!( - ClickPipesGcpWorkloadIdentityContext, - ["supported", "ready", "principal"] - ); - tolerant!(ClickPipesServiceContext, ["gcpWorkloadIdentity"]); - tolerant!( - ClickStackFormulaResponse, - ["expression", "alias", "numberFormat"] - ); - tolerant!(ClickStackSqlSavedFilterValueResponse, ["type", "condition"]); - tolerant!( - ClickStackVariableSavedFilterValueResponse, - ["type", "name", "values"] - ); -} - -#[test] -fn clickpipes_workload_identity_sources_round_trip_without_credentials() { - let bigquery = serde_json::json!({ - "authentication": "SERVICE_ACCOUNT_WORKLOAD_IDENTITY", "projectId": "project", - "snapshotStagingPath": "gs://bucket/staging", "settings": {"replicationMode": "snapshot"}, - "tableMappings": [{"sourceDatasetName": "dataset", "sourceTable": "source", "targetTable": "target"}] - }); - let parsed: ClickPipeMutateBigQuerySource = serde_json::from_value(bigquery.clone()).unwrap(); - assert!(matches!( - parsed, - ClickPipeMutateBigQuerySource::ClickPipePostBigQueryWorkloadIdentitySource(_) - )); - assert_eq!(serde_json::to_value(parsed).unwrap(), bigquery); - let pubsub = serde_json::json!({ - "authentication": "SERVICE_ACCOUNT_WORKLOAD_IDENTITY", "projectId": "project", - "topic": "topic", "format": "JSONEachRow", "seekType": "earliest" - }); - let parsed: ClickPipePostPubSubSource = serde_json::from_value(pubsub.clone()).unwrap(); - assert!(matches!( - parsed, - ClickPipePostPubSubSource::ClickPipePostPubSubWorkloadIdentitySource(_) - )); - assert_eq!(serde_json::to_value(parsed).unwrap(), pubsub); - assert!( - serde_json::from_value::( - serde_json::json!({}) - ) - .is_err() - ); - assert!( - serde_json::from_value::(serde_json::json!({})) - .is_err() - ); - assert!( - serde_json::from_value::(serde_json::json!({})) - .is_err() - ); - assert!( - serde_json::from_value::(serde_json::json!({})) - .is_err() - ); -} - -#[test] -fn bigquery_service_account_allows_omitted_authentication() { - let json = serde_json::json!({ - "snapshotStagingPath": "gs://bucket/staging", "settings": {"replicationMode": "snapshot"}, - "tableMappings": [], "credentials": {"serviceAccountFile": "encoded-key"} - }); - let parsed: ClickPipeMutateBigQuerySource = serde_json::from_value(json.clone()).unwrap(); - assert!(matches!( - parsed, - ClickPipeMutateBigQuerySource::ClickPipePostBigQueryServiceAccountSource(_) - )); - assert_eq!(serde_json::to_value(parsed).unwrap(), json); -} - -#[test] -fn new_source_and_saved_filter_unions_preserve_unknown_payloads() { - assert_unknown_variant_round_trips::( - r#"{"authentication":"FUTURE","newField":1}"#, - |v| matches!(v, ClickPipeMutateBigQuerySource::Unknown(_)), - ); - assert_unknown_variant_round_trips::( - r#"{"authentication":"FUTURE","newField":1}"#, - |v| matches!(v, ClickPipeBigQuerySource::Unknown(_)), - ); - assert_unknown_variant_round_trips::( - r#"{"authentication":"FUTURE","newField":1}"#, - |v| matches!(v, ClickPipePostPubSubSource::Unknown(_)), - ); - assert_unknown_variant_round_trips::( - r#"{"type":"future","values":["keep"]}"#, - |v| matches!(v, ClickStackSavedFilterValueResponse::Unknown(_)), - ); - assert_unknown_variant_round_trips::( - r#"{"name":"service","values":["keep"]}"#, - |v| matches!(v, ClickStackSavedFilterValueResponse::Unknown(_)), - ); - assert_unknown_variant_round_trips::( - r#"{"authentication":"SERVICE_ACCOUNT","settings":"new-shape"}"#, - |v| matches!(v, ClickPipeBigQuerySource::Unknown(_)), - ); -} - -#[test] -fn saved_filter_and_formula_writeback_reports_required_wire_fields() { - let sql: ClickStackSavedFilterValueResponse = - serde_json::from_value(serde_json::json!({"condition": "x = 1"})).unwrap(); - let request = ClickStackSavedFilterValue::try_from(sql).unwrap(); - assert_eq!( - serde_json::to_value(request).unwrap(), - serde_json::json!({"condition": "x = 1"}) - ); - let variable = serde_json::json!({"type": "variable", "name": "service", "values": ["api"]}); - let response: ClickStackSavedFilterValueResponse = - serde_json::from_value(variable.clone()).unwrap(); - assert_eq!( - serde_json::to_value(ClickStackSavedFilterValue::try_from(response).unwrap()).unwrap(), - variable - ); - assert_eq!( - ClickStackSqlSavedFilterValue::try_from(ClickStackSqlSavedFilterValueResponse::default()) - .unwrap_err() - .fields(), - &["condition"] - ); - assert_eq!( - ClickStackVariableSavedFilterValue::try_from( - ClickStackVariableSavedFilterValueResponse::default() - ) - .unwrap_err() - .fields(), - &["type", "name", "values"] - ); - assert_eq!( - ClickStackFormula::try_from(ClickStackFormulaResponse::default()) - .unwrap_err() - .fields(), - &["expression"] - ); - let formula = ClickStackFormula::try_from(ClickStackFormulaResponse { - expression: Some("A / B".into()), - alias: Some("Rate".into()), - number_format: None, - }) - .unwrap(); - assert_eq!(formula.expression, "A / B"); - assert_eq!(formula.alias.as_deref(), Some("Rate")); - let invalid = ClickStackFormulaResponse { - expression: Some("A".into()), - number_format: Some(ClickStackNumberFormatResponse::default()), - ..Default::default() - }; - assert!( - ClickStackFormula::try_from(invalid) - .unwrap_err() - .fields() - .contains(&"currencySymbol") - ); -} - -#[test] -fn kafka_inline_protobuf_schema_and_schema_registry_are_independent_options() { - let json_source = ClickPipePostKafkaSource { - format: ClickPipePostKafkaSourceFormat::JSONEachRow, - ..Default::default() - }; - assert!( - serde_json::to_value(json_source) - .unwrap() - .get("protobufSchema") - .is_none() - ); - let inline = ClickPipePostKafkaSource { - format: ClickPipePostKafkaSourceFormat::Protobuf, - protobuf_schema: Some("c3ludGF4".into()), - ..Default::default() - }; - let wire = serde_json::to_value(inline).unwrap(); - assert_eq!(wire["protobufSchema"], "c3ludGF4"); - assert!(wire.get("schemaRegistry").is_none()); - let registry = ClickPipePostKafkaSource { - format: ClickPipePostKafkaSourceFormat::Protobuf, - schema_registry: Some(ClickPipeMutateKafkaSchemaRegistry::default()), - ..Default::default() - }; - let wire = serde_json::to_value(registry).unwrap(); - assert!(wire.get("protobufSchema").is_none()); - assert!(wire.get("schemaRegistry").is_some()); -} - -#[test] -fn new_clickstack_channels_and_chart_fields_round_trip() { - let request = ClickStackCreateAlertRequest { - interval: ClickStackCreateAlertRequestInterval::Value30s, - channel: Some(ClickStackAlertChannel::default()), - channels: Some(vec![ClickStackAlertChannel::default()]), - ..Default::default() - }; - let wire = serde_json::to_value(&request).unwrap(); - assert_eq!(wire["channels"].as_array().unwrap().len(), 1); - assert_eq!(wire["interval"], "30s"); - assert!(wire.get("channel").is_some()); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - request - ); - let chart: ClickStackLineBuilderChartConfigResponse = - serde_json::from_value(serde_json::json!({ - "formulas": [{"expression": "A / B", "alias": "Rate"}], "seriesLimit": 15, - "showOperandSeries": false - })) - .unwrap(); - assert_eq!(chart.series_limit, Some(15)); - assert_eq!(chart.show_operand_series, Some(false)); - assert_eq!( - chart.formulas.unwrap()[0].expression.as_deref(), - Some("A / B") - ); -} - -#[test] -fn clickstack_nested_writeback_preserves_number_format_and_source_expressions() { - let formula = serde_json::json!({ - "expression": "A / B", "alias": "Success rate", "numberFormat": { - "average": false, "currencySymbol": "$", "decimalBytes": true, - "factor": 100.0, "mantissa": 2, "numericUnit": "bytes_si", - "output": "percent", "thousandSeparated": true, "unit": "%" - } - }); - let response: ClickStackFormulaResponse = serde_json::from_value(formula.clone()).unwrap(); - let request = ClickStackFormula::try_from(response).unwrap(); - assert_eq!(serde_json::to_value(request).unwrap(), formula); - - let log = serde_json::json!({ - "kind": "log", "connection": "connection-1", "name": "Application logs", - "defaultTableSelectExpression": "*", "timestampValueExpression": "Timestamp", - "from": {"databaseName": "default", "tableName": "otel_logs"}, - "serviceVersionExpression": "ResourceAttributes['service.version']", - "filterSettings": { - "databaseName": "default", "tableName": "service_names", - "columns": [{"name": "ServiceName", "label": "Service", "allowAll": true, - "valueExpression": "lower(service_name)"}] - } - }); - let response: ClickStackSourceResponse = serde_json::from_value(log.clone()).unwrap(); - let request = ClickStackSource::try_from(response).unwrap(); - assert_eq!(serde_json::to_value(request).unwrap(), log); - - let mut trace = log; - trace["kind"] = "trace".into(); - trace["name"] = "Application traces".into(); - trace["from"]["tableName"] = "otel_traces".into(); - trace["durationExpression"] = "Duration".into(); - trace["durationPrecision"] = 9.into(); - trace["parentSpanIdExpression"] = "ParentSpanId".into(); - trace["spanIdExpression"] = "SpanId".into(); - trace["spanKindExpression"] = "SpanKind".into(); - trace["spanNameExpression"] = "SpanName".into(); - trace["traceIdExpression"] = "TraceId".into(); - let response: ClickStackSourceResponse = serde_json::from_value(trace.clone()).unwrap(); - let request = ClickStackSource::try_from(response).unwrap(); - assert_eq!(serde_json::to_value(request).unwrap(), trace); -} - -#[test] -fn pgbouncer_map_round_trips_arbitrary_string_parameters() { - let document = serde_json::json!({ - "default_pool_size": "16", "future_parameter": "on", "empty": "" - }); - let request: PgBouncerConfig = serde_json::from_value(document.clone()).unwrap(); - let response: PgBouncerConfigResponse = serde_json::from_value(document.clone()).unwrap(); - assert_eq!(request, response); - assert_eq!(serde_json::to_value(request).unwrap(), document); - assert_eq!(serde_json::to_value(response).unwrap(), document); -} - -#[test] -fn pgbouncer_requests_reject_non_string_values() { - for value in [ - serde_json::json!(16), - serde_json::json!(true), - serde_json::json!(null), - serde_json::json!([]), - serde_json::json!({}), - ] { - assert!( - serde_json::from_value::( - serde_json::json!({"default_pool_size": value}) - ) - .is_err() - ); - } -} - -#[test] -fn pgbouncer_parent_responses_preserve_missing_null_and_empty_sections() { - for document in [ - serde_json::json!({}), - serde_json::json!({"pgBouncerConfig": null}), - ] { - let config: PostgresInstanceConfigResponse = - serde_json::from_value(document.clone()).unwrap(); - assert!(config.pg_bouncer_config.is_none()); - assert_eq!(serde_json::to_value(config).unwrap(), serde_json::json!({})); - let update: PostgresInstanceUpdateConfigResponse = - serde_json::from_value(document).unwrap(); - assert!(update.pg_bouncer_config.is_none()); - assert_eq!(serde_json::to_value(update).unwrap(), serde_json::json!({})); - } - let config: PostgresInstanceConfigResponse = serde_json::from_value( - serde_json::json!({"pgBouncerConfig": {}, "futureField": "ignored"}), - ) - .unwrap(); - assert_eq!(config.pg_bouncer_config, Some(PgBouncerConfig::new())); - assert_eq!( - serde_json::to_value(config).unwrap(), - serde_json::json!({"pgBouncerConfig": {}}) - ); -} - -#[test] -fn api_key_patch_expiry_preserves_omit_set_and_clear() { - let timestamp = chrono::DateTime::parse_from_rfc3339("2030-01-02T03:04:05Z") - .unwrap() - .with_timezone(&chrono::Utc); - for (expire_at, wire) in [ - (None, serde_json::json!({})), - ( - Some(Some(timestamp)), - serde_json::json!({"expireAt": "2030-01-02T03:04:05Z"}), - ), - (Some(None), serde_json::json!({"expireAt": null})), - ] { - let request = ApiKeyPatchRequest { - expire_at, - ..Default::default() - }; - assert_eq!(serde_json::to_value(&request).unwrap(), wire); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - request - ); - } -} - -#[test] -fn api_key_patch_deserialization_preserves_other_fields_and_ignores_unknowns() { - let wire = serde_json::json!({ - "expireAt": null, - "assignedRoleIds": ["aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"], - "ipAccessList": [{"source": "10.0.0.0/8", "description": "private"}], - "name": "renamed", - "state": "disabled" - }); - let mut extended = wire.clone(); - extended["newServerField"] = serde_json::json!({"nested": true}); - let request: ApiKeyPatchRequest = serde_json::from_value(extended).unwrap(); - assert_eq!(serde_json::to_value(request).unwrap(), wire); - let nulls: ApiKeyPatchRequest = serde_json::from_value(serde_json::json!({ - "assignedRoleIds": null, "ipAccessList": null, "name": null, "state": null - })) - .unwrap(); - assert_eq!(nulls, ApiKeyPatchRequest::default()); -} - -#[test] -fn api_key_patch_deserialization_rejects_invalid_recognized_fields() { - for wire in [ - serde_json::json!({"expireAt": "tomorrow"}), - serde_json::json!({"expireAt": 42}), - serde_json::json!({"assignedRoleIds": ["invalid"]}), - serde_json::json!({"ipAccessList": [{}]}), - serde_json::json!({"name": false}), - serde_json::json!({"state": {}}), - serde_json::json!(null), - serde_json::json!([]), - ] { - assert!( - serde_json::from_value::(wire.clone()).is_err(), - "accepted {wire}" - ); - } -} - -#[test] -fn kafka_patch_source_preserves_partial_and_explicit_list_updates() { - let partial: ClickPipePatchKafkaSource = serde_json::from_value(serde_json::json!({ - "caCertificate": "new-ca" - })) - .unwrap(); - assert!(partial.credentials.is_none()); - assert!(partial.reverse_private_endpoint_ids.is_none()); - assert_eq!( - serde_json::to_value(partial).unwrap(), - serde_json::json!({"caCertificate": "new-ca"}) - ); - - let cleared: ClickPipePatchKafkaSource = serde_json::from_value(serde_json::json!({ - "reversePrivateEndpointIds": [] - })) - .unwrap(); - assert_eq!(cleared.reverse_private_endpoint_ids, Some(vec![])); - assert_eq!( - serde_json::to_value(cleared).unwrap(), - serde_json::json!({"reversePrivateEndpointIds": []}) - ); - - let nulls: ClickPipePatchKafkaSource = serde_json::from_value(serde_json::json!({ - "credentials": null, - "reversePrivateEndpointIds": null - })) - .unwrap(); - assert_eq!(nulls, ClickPipePatchKafkaSource::default()); - assert_eq!(serde_json::to_value(nulls).unwrap(), serde_json::json!({})); -} - -#[test] -fn clickpipe_patch_nested_objects_preserve_omission_and_explicit_empty_values() { - let partial = serde_json::json!({ - "source": { - "postgres": {"host": "postgres.example.com"} - } - }); - let request: ClickPipePatchRequest = serde_json::from_value(partial.clone()).unwrap(); - assert_eq!(serde_json::to_value(request).unwrap(), partial); - - let explicit = serde_json::json!({ - "destination": {"columns": []}, - "source": { - "postgres": { - "tableMappingsToAdd": [], - "tableMappingsToRemove": [] - }, - "validateSamples": false - } - }); - let request: ClickPipePatchRequest = serde_json::from_value(explicit.clone()).unwrap(); - assert_eq!(serde_json::to_value(request).unwrap(), explicit); - - let nulls: ClickPipePatchRequest = serde_json::from_value(serde_json::json!({ - "source": { - "postgres": { - "credentials": null, - "settings": null, - "tableMappingsToAdd": null, - "tableMappingsToRemove": null - }, - "validateSamples": null - } - })) - .unwrap(); - assert_eq!( - serde_json::to_value(nulls).unwrap(), - serde_json::json!({"source": {"postgres": {}}}) - ); -} - -#[cfg(feature = "deprecated-fields")] -#[test] -fn api_key_patch_deserialization_preserves_deprecated_roles() { - let wire = serde_json::json!({"expireAt": null, "roles": ["admin"]}); - let request: ApiKeyPatchRequest = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(serde_json::to_value(request).unwrap(), wire); -} - -#[test] -fn role_update_request_omits_fields_that_are_not_being_changed() { - let request = RoleUpdateRequest { - name: Some("auditor".to_string()), - actors: None, - policies: None, - }; - - assert_eq!( - serde_json::to_value(request).unwrap(), - serde_json::json!({"name": "auditor"}) - ); -} - -#[test] -fn udf_request_inline_variants_preserve_determinism_and_memory() { - fn check(kind: &str, create: bool) { - let mut input = serde_json::json!({"uploadId": "upload-1", "runtime": "native", - "arguments": [], "returnType": "UInt64", "type": kind}); - if create { - input["functionName"] = serde_json::json!("my_udf"); - } - let minimal: T = serde_json::from_value(input.clone()).unwrap(); - assert_eq!(serde_json::to_value(minimal).unwrap(), input); - input["deterministic"] = serde_json::json!(false); - input["memoryLimitMib"] = serde_json::json!(128); - let full: T = serde_json::from_value(input.clone()).unwrap(); - assert_eq!(serde_json::to_value(full).unwrap(), input); - input["memoryLimitMib"] = serde_json::Value::Null; - let nullable: T = serde_json::from_value(input.clone()).unwrap(); - input.as_object_mut().unwrap().remove("memoryLimitMib"); - assert_eq!(serde_json::to_value(nullable).unwrap(), input); - input.as_object_mut().unwrap().remove("runtime"); - assert!(serde_json::from_value::(input).is_err()); - } - check::("executable", true); - check::("executable_pool", true); - check::("executable", false); - check::("executable_pool", false); -} - -#[test] -fn postgres_slow_query_durations_preserve_fractions_and_integral_counts() { - let aggregate = serde_json::json!({ - "avgDurationUs": 1190.8419405320817, - "maxDurationUs": 2000.75, - "p50DurationUs": 1100.25, - "p95DurationUs": 1800.5, - "p99DurationUs": 1950.125, - "totalDurationUs": 3572.525821596245, - "callCount": 3, "errorCount": 0, "totalRows": 9007199254740993_i64, - "totalSharedBlksHit": 10, "totalSharedBlksRead": 2, - "totalCpuTimeUs": 2000, "totalWalBytes": 128 - }); - let list: Vec = - serde_json::from_value(serde_json::json!([aggregate])).unwrap(); - assert_eq!(list[0].avg_duration_us, Some(1190.8419405320817)); - assert_eq!(list[0].total_rows, Some(9007199254740993)); - assert_eq!( - serde_json::to_value(&list).unwrap(), - serde_json::json!([aggregate]) - ); - - let detail = serde_json::json!({ - "aggregate": aggregate, - "recentExecutions": [{"durationUs": 1234.56789, "rows": 9007199254740993_i64, - "cpuSysTimeUs": 12, "jitFunctions": 2, "sharedBlksHit": 3, "walBytes": 128}] - }); - let parsed: PostgresSlowQueryPatternDetail = serde_json::from_value(detail.clone()).unwrap(); - assert_eq!( - parsed.recent_executions.as_ref().unwrap()[0].duration_us, - Some(1234.56789) - ); - assert_eq!(serde_json::to_value(parsed).unwrap(), detail); - - // Existing integer-valued measurements remain accepted as durations. - let integer: PostgresQueryExecution = - serde_json::from_value(serde_json::json!({"durationUs": 1234})).unwrap(); - assert_eq!(integer.duration_us, Some(1234.0)); - let integer: PostgresSlowQueryPattern = serde_json::from_value(serde_json::json!({ - "avgDurationUs": 1, "maxDurationUs": 2, "p50DurationUs": 1, - "p95DurationUs": 2, "p99DurationUs": 2, "totalDurationUs": 3 - })) - .unwrap(); - assert_eq!(integer.avg_duration_us, Some(1.0)); - for field in [ - "callCount", - "errorCount", - "totalRows", - "totalSharedBlksHit", - "totalSharedBlksRead", - "totalWalBytes", - ] { - assert!( - serde_json::from_value::(serde_json::json!({field: 1.5})) - .is_err(), - "{field} must remain integral" - ); - } - for field in ["rows", "jitFunctions", "sharedBlksHit", "walBytes"] { - assert!( - serde_json::from_value::(serde_json::json!({field: 1.5})) - .is_err(), - "{field} must remain integral" - ); - } -} - -#[test] -fn postgres_slow_query_durations_allow_missing_and_null() { - for aggregate in [ - serde_json::json!({}), - serde_json::json!({ - "avgDurationUs": null, "maxDurationUs": null, "p50DurationUs": null, - "p95DurationUs": null, "p99DurationUs": null, "totalDurationUs": null - }), - ] { - let parsed: PostgresSlowQueryPattern = serde_json::from_value(aggregate.clone()).unwrap(); - assert_eq!(parsed, PostgresSlowQueryPattern::default()); - assert_eq!(serde_json::to_value(parsed).unwrap(), serde_json::json!({})); - let detail: PostgresSlowQueryPatternDetail = serde_json::from_value(serde_json::json!({ - "aggregate": aggregate, "recentExecutions": [{}, {"durationUs": null}] - })) - .unwrap(); - assert_eq!(detail.aggregate, Some(PostgresSlowQueryPattern::default())); - assert_eq!( - detail.recent_executions, - Some(vec![PostgresQueryExecution::default(); 2]) - ); - assert_eq!( - serde_json::to_value(detail).unwrap(), - serde_json::json!({"aggregate": {}, "recentExecutions": [{}, {}]}) - ); - } - for value in [ - serde_json::json!({}), - serde_json::json!({"aggregate": null, "recentExecutions": null}), - ] { - let parsed: PostgresSlowQueryPatternDetail = serde_json::from_value(value).unwrap(); - assert_eq!(parsed, PostgresSlowQueryPatternDetail::default()); - assert_eq!(serde_json::to_value(parsed).unwrap(), serde_json::json!({})); - } -} - -#[test] -fn service_clickhouse_settings_typed_request_preserves_native_values() { - let settings = serde_json::json!({ - "compatibility": "26.2", "max_query_size": 262146, - "mark_cache_ram_ratio": "0.5", "signed": -42, - "unsigned": u64::MAX - }); - let map: ServiceClickhouseSettingsMap = serde_json::from_value(settings.clone()).unwrap(); - assert_eq!( - map["compatibility"], - ServiceClickhouseSettingValue::String("26.2".into()) - ); - assert_eq!( - map["max_query_size"], - ServiceClickhouseSettingValue::Integer(262146) - ); - assert_eq!( - map["unsigned"], - ServiceClickhouseSettingValue::UnsignedInteger(u64::MAX) - ); - let request = ServiceClickhouseSettingsPatchRequest { - settings: Some(map), - }; - assert_eq!( - serde_json::to_value(request).unwrap(), - serde_json::json!({"settings": settings}) - ); - let empty = ServiceClickhouseSettingsPatchRequest { - settings: Some(ServiceClickhouseSettingsMap::new()), - }; - assert!(serde_json::to_value(empty).is_err()); -} - -#[test] -fn service_clickhouse_settings_typed_request_rejects_unsupported_value_types() { - for value in [ - serde_json::json!(true), - serde_json::Value::Null, - serde_json::json!(1.5), - serde_json::json!({"nested": true}), - serde_json::json!([1]), - ] { - assert!( - serde_json::from_value::( - serde_json::json!({"setting": value}) - ) - .is_err() - ); - } -} - -#[test] -fn service_clickhouse_settings_preserves_dynamic_json_values() { - let settings = serde_json::json!({ - "string": "26.2", "integer": 262144, "boolean": false, "null": null, - "array": [1, "two"], "future": {"nested": true} - }); - let request: ServiceClickhouseSettingsPatchRequest = - serde_json::from_value(serde_json::json!({"settings": settings})).unwrap(); - assert_eq!( - serde_json::to_value(request).unwrap(), - serde_json::json!({"settings": settings}) - ); - let response: ServiceClickhouseSettingsPatchResponse = - serde_json::from_value(serde_json::json!({"settings": settings})).unwrap(); - assert_eq!( - serde_json::to_value(response).unwrap(), - serde_json::json!({"settings": settings}) - ); - for sparse in [ - serde_json::json!({}), - serde_json::json!({"settings": null, "warnings": null}), - ] { - let response: ServiceClickhouseSettingsPatchResponse = - serde_json::from_value(sparse).unwrap(); - assert!(response.settings.is_none()); - assert!(response.warnings.is_none()); - assert_eq!( - serde_json::to_value(response).unwrap(), - serde_json::json!({}) - ); - } - let empty = ServiceClickhouseSettingsPatchRequest { - settings: Some(std::collections::BTreeMap::::new()), - }; - assert!(serde_json::to_value(empty).is_err()); -} - -#[test] -fn service_clickhouse_setting_preserves_json_value_types() { - for value in [ - serde_json::json!(262146), - serde_json::json!(18446744073709551615_u64), - serde_json::json!(-42), - serde_json::json!(1.25), - serde_json::json!("26.2"), - serde_json::json!("262146"), - serde_json::json!(false), - serde_json::json!({"future": [1, true]}), - ] { - let wire = serde_json::json!({"name": "setting", "value": value}); - let setting: ServiceClickhouseSetting = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(setting.value.as_ref(), Some(&value)); - assert_eq!(serde_json::to_value(&setting).unwrap(), wire); - let list_wire = serde_json::json!({"settings": [wire]}); - let list: ServiceClickhouseSettingsList = - serde_json::from_value(list_wire.clone()).unwrap(); - assert_eq!(serde_json::to_value(list).unwrap(), list_wire); - } -} - -#[test] -fn service_clickhouse_setting_missing_and_null_values_remain_absent() { - for wire in [ - serde_json::json!({}), - serde_json::json!({"name": null, "value": null}), - ] { - let setting: ServiceClickhouseSetting = serde_json::from_value(wire).unwrap(); - assert_eq!(setting.name, None); - assert_eq!(setting.value, None); - assert_eq!( - serde_json::to_value(setting).unwrap(), - serde_json::json!({}) - ); - } -} - -#[test] -fn kinesis_protobuf_schema_round_trips_and_other_formats_omit_it() { - let json_source = ClickPipePostKinesisSource::default(); - let wire = serde_json::to_value(&json_source).unwrap(); - assert!(wire.get("protobufSchema").is_none()); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - json_source - ); - - let protobuf = ClickPipePostKinesisSource { - format: ClickPipePostKinesisSourceFormat::Protobuf, - protobuf_schema: Some("c3ludGF4".into()), - ..Default::default() - }; - let wire = serde_json::to_value(&protobuf).unwrap(); - assert_eq!(wire["format"], "Protobuf"); - assert_eq!(wire["protobufSchema"], "c3ludGF4"); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - protobuf - ); - assert_eq!(protobuf.format.to_string(), "Protobuf"); - - let response: ClickPipeKinesisSource = - serde_json::from_value(serde_json::json!({"format": "Protobuf"})).unwrap(); - assert_eq!( - response.format, - Some(ClickPipeKinesisSourceFormat::Protobuf) - ); - assert_eq!(response.format.unwrap().to_string(), "Protobuf"); - assert!( - matches!(serde_json::from_str::("\"FutureFormat\"").unwrap(), ClickPipeKinesisSourceFormat::Unknown(value) if value == "FutureFormat") - ); -} - -#[test] -fn organization_capabilities_tolerate_absence_null_and_false() { - for wire in [ - serde_json::json!({}), - serde_json::json!({"capabilities": null}), - ] { - let org: Organization = serde_json::from_value(wire).unwrap(); - assert_eq!(org.capabilities, None); - assert_eq!(serde_json::to_value(org).unwrap(), serde_json::json!({})); - } - for wire in [ - serde_json::json!({}), - serde_json::json!({"snapshots": null}), - ] { - let capabilities: OrganizationCapabilities = serde_json::from_value(wire).unwrap(); - assert_eq!(capabilities.snapshots, None); - assert_eq!( - serde_json::to_value(capabilities).unwrap(), - serde_json::json!({}) - ); - } - let org: Organization = serde_json::from_value(serde_json::json!({ - "capabilities": {"snapshots": false} - })) - .unwrap(); - assert_eq!(org.capabilities.unwrap().snapshots, Some(false)); -} - -#[test] -fn kinesis_registry_request_is_strict_and_response_is_tolerant() { - let full = serde_json::json!({ - "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events" - }); - let request: ClickPipeKinesisSchemaRegistry = serde_json::from_value(full.clone()).unwrap(); - assert_eq!(serde_json::to_value(request).unwrap(), full); - for field in ["type", "glueRegion", "glueRegistryName"] { - let mut missing = full.clone(); - missing.as_object_mut().unwrap().remove(field); - assert!(serde_json::from_value::(missing).is_err()); - let mut null = full.clone(); - null[field] = serde_json::Value::Null; - assert!(serde_json::from_value::(null).is_err()); - } - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "type": null, "glueRegion": null, "glueRegistryName": null, "glueRoleArn": null - }), - ] { - let response: ClickPipeKinesisSchemaRegistryResponse = - serde_json::from_value(wire).unwrap(); - assert_eq!(response, ClickPipeKinesisSchemaRegistryResponse::default()); - assert_eq!( - serde_json::to_value(response).unwrap(), - serde_json::json!({}) - ); - } -} - -#[test] -fn kinesis_registry_conversion_reports_nested_missing_fields_and_preserves_unknown_type() { - let response: ClickPipeKinesisSchemaRegistryResponse = - serde_json::from_value(serde_json::json!({ - "type": "future", "glueRegion": "us-east-1", "glueRoleArn": "arn:aws:iam::123:role/Glue" - })) - .unwrap(); - let error = ClickPipeKinesisSchemaRegistry::try_from(response.clone()).unwrap_err(); - assert_eq!(error.fields(), &["glueRegistryName"]); - let mut complete = response; - complete.glue_registry_name = Some("events".into()); - let request = ClickPipeKinesisSchemaRegistry::try_from(complete).unwrap(); - assert_eq!( - serde_json::to_value(request).unwrap(), - serde_json::json!({ - "type": "future", "glueRegion": "us-east-1", "glueRegistryName": "events", - "glueRoleArn": "arn:aws:iam::123:role/Glue" - }) - ); - assert_eq!( - ClickPipeKinesisSchemaRegistryType::Unknown("future".into()).to_string(), - "future" - ); -} - -#[test] -fn kafka_schema_registry_response_dispatches_legacy_confluent_and_glue() { - // Payloads written before Glue support carry no `type`; they stay Confluent. - for wire in [ - serde_json::json!({ - "url": "https://registry.example", "authentication": "PLAIN", - "caCertificate": "CA" - }), - serde_json::json!({ - "type": "confluent", "url": "https://registry.example", - "authentication": "PLAIN", "caCertificate": "CA" - }), - ] { - let registry: ClickPipeKafkaSchemaRegistry = serde_json::from_value(wire.clone()).unwrap(); - let ClickPipeKafkaSchemaRegistry::ClickPipeKafkaConfluentSchemaRegistry(confluent) = - ®istry - else { - panic!("expected Confluent, got {registry:?}"); - }; - assert_eq!(confluent.url.as_deref(), Some("https://registry.example")); - assert_eq!( - confluent.authentication, - Some(ClickPipeKafkaConfluentSchemaRegistryAuthentication::PLAIN) - ); - assert_eq!(confluent.ca_certificate.as_deref(), Some("CA")); - assert_eq!(serde_json::to_value(®istry).unwrap(), wire); - } - - let glue = serde_json::json!({ - "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events", - "glueRoleArn": "arn:aws:iam::123:role/Glue" - }); - let registry: ClickPipeKafkaSchemaRegistry = serde_json::from_value(glue.clone()).unwrap(); - let ClickPipeKafkaSchemaRegistry::ClickPipeKafkaGlueSchemaRegistryResponse(response) = - ®istry - else { - panic!("expected Glue, got {registry:?}"); - }; - assert_eq!( - response.r#type, - Some(ClickPipeKafkaGlueSchemaRegistryType::Glue) - ); - assert_eq!(response.glue_region.as_deref(), Some("us-east-1")); - assert_eq!(response.glue_registry_name.as_deref(), Some("events")); - assert_eq!(serde_json::to_value(®istry).unwrap(), glue); - - // A Glue payload that lost its discriminator, or a future registry type, - // is kept verbatim instead of being misread as Confluent. - for wire in [ - serde_json::json!({"glueRegion": "us-east-1", "glueRegistryName": "events"}), - serde_json::json!({"type": "future", "url": "https://registry.example"}), - ] { - let registry: ClickPipeKafkaSchemaRegistry = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(registry, ClickPipeKafkaSchemaRegistry::Unknown(wire)); - } - - let default = serde_json::to_value(ClickPipeKafkaSchemaRegistry::default()).unwrap(); - assert_eq!(default, serde_json::json!({})); - assert_eq!( - serde_json::from_value::(default).unwrap(), - ClickPipeKafkaSchemaRegistry::default() - ); -} - -#[test] -fn kafka_schema_registry_variants_tolerate_missing_and_null_fields() { - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "type": null, "url": null, "authentication": null, "caCertificate": null - }), - ] { - let confluent: ClickPipeKafkaConfluentSchemaRegistry = - serde_json::from_value(wire).unwrap(); - assert_eq!(confluent, ClickPipeKafkaConfluentSchemaRegistry::default()); - assert_eq!( - serde_json::to_value(confluent).unwrap(), - serde_json::json!({}) - ); - } - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "type": null, "glueRegion": null, "glueRegistryName": null, "glueRoleArn": null - }), - ] { - let glue: ClickPipeKafkaGlueSchemaRegistryResponse = serde_json::from_value(wire).unwrap(); - assert_eq!(glue, ClickPipeKafkaGlueSchemaRegistryResponse::default()); - assert_eq!(serde_json::to_value(glue).unwrap(), serde_json::json!({})); - } -} - -#[test] -fn kafka_source_response_schema_registry_may_be_missing_or_null() { - for wire in [ - serde_json::json!({"brokers": "broker:9092"}), - serde_json::json!({"brokers": "broker:9092", "schemaRegistry": null}), - ] { - let source: ClickPipeKafkaSource = serde_json::from_value(wire).unwrap(); - assert_eq!(source.schema_registry, None); - assert!( - serde_json::to_value(source) - .unwrap() - .get("schemaRegistry") - .is_none() - ); - } - let source: ClickPipeKafkaSource = serde_json::from_value(serde_json::json!({ - "schemaRegistry": {"type": "glue", "glueRegion": "eu-west-1", "glueRegistryName": "r"} - })) - .unwrap(); - assert!(matches!( - source.schema_registry, - Some(ClickPipeKafkaSchemaRegistry::ClickPipeKafkaGlueSchemaRegistryResponse(_)) - )); -} - -#[test] -fn kafka_schema_registry_requests_serialize_each_variant_wire_shape() { - let confluent: ClickPipeMutateKafkaSchemaRegistry = - ClickPipeMutateKafkaConfluentSchemaRegistry { - r#type: None, - url: "https://registry.example".into(), - authentication: ClickPipeMutateKafkaConfluentSchemaRegistryAuthentication::PLAIN, - credentials: ClickPipeKafkaSchemaRegistryCredentials { - username: "user".into(), - password: "secret".into(), - }, - ca_certificate: None, - } - .into(); - assert_eq!( - serde_json::to_value(&confluent).unwrap(), - serde_json::json!({ - "url": "https://registry.example", "authentication": "PLAIN", - "credentials": {"username": "user", "password": "secret"} - }) - ); - let ClickPipeMutateKafkaSchemaRegistry::ClickPipeMutateKafkaConfluentSchemaRegistry( - mut explicit, - ) = confluent - else { - unreachable!() - }; - explicit.r#type = Some(ClickPipeMutateKafkaConfluentSchemaRegistryType::Confluent); - explicit.ca_certificate = Some("CA".into()); - let explicit = ClickPipeMutateKafkaSchemaRegistry::from(explicit); - let wire = serde_json::json!({ - "type": "confluent", "url": "https://registry.example", "authentication": "PLAIN", - "credentials": {"username": "user", "password": "secret"}, "caCertificate": "CA" - }); - assert_eq!(serde_json::to_value(&explicit).unwrap(), wire); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - explicit - ); - - let glue: ClickPipeMutateKafkaSchemaRegistry = ClickPipeKafkaGlueSchemaRegistry { - r#type: ClickPipeKafkaGlueSchemaRegistryType::Glue, - glue_region: "us-east-1".into(), - glue_registry_name: "events".into(), - glue_role_arn: None, - } - .into(); - let wire = serde_json::json!({ - "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events" - }); - assert_eq!(serde_json::to_value(&glue).unwrap(), wire); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - glue - ); - let source = ClickPipePostKafkaSource { - schema_registry: Some(glue), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(source).unwrap()["schemaRegistry"]["type"], - "glue" - ); -} - -#[test] -fn kafka_schema_registry_request_variants_are_strict() { - let confluent = serde_json::json!({ - "url": "https://registry.example", "authentication": "PLAIN", - "credentials": {"username": "user", "password": "secret"} - }); - serde_json::from_value::(confluent.clone()) - .unwrap(); - for field in ["url", "authentication", "credentials"] { - let mut missing = confluent.clone(); - missing.as_object_mut().unwrap().remove(field); - assert!( - serde_json::from_value::(missing).is_err() - ); - } - let glue = serde_json::json!({ - "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events" - }); - for field in ["type", "glueRegion", "glueRegistryName"] { - let mut missing = glue.clone(); - missing.as_object_mut().unwrap().remove(field); - assert!(serde_json::from_value::(missing).is_err()); - let mut null = glue.clone(); - null[field] = serde_json::Value::Null; - assert!(serde_json::from_value::(null).is_err()); - } -} - -#[test] -fn kafka_glue_registry_conversion_reports_missing_fields() { - let response: ClickPipeKafkaGlueSchemaRegistryResponse = - serde_json::from_value(serde_json::json!({ - "glueRegion": "us-east-1", "glueRoleArn": "arn:aws:iam::123:role/Glue" - })) - .unwrap(); - let error = ClickPipeKafkaGlueSchemaRegistry::try_from(response.clone()).unwrap_err(); - assert_eq!(error.fields(), &["type", "glueRegistryName"]); - let mut complete = response; - complete.r#type = Some(ClickPipeKafkaGlueSchemaRegistryType::Glue); - complete.glue_registry_name = Some("events".into()); - let request = ClickPipeKafkaGlueSchemaRegistry::try_from(complete).unwrap(); - assert_eq!( - serde_json::to_value(request).unwrap(), - serde_json::json!({ - "type": "glue", "glueRegion": "us-east-1", "glueRegistryName": "events", - "glueRoleArn": "arn:aws:iam::123:role/Glue" - }) - ); - assert_eq!( - ClickPipeKafkaGlueSchemaRegistryType::Glue.to_string(), - "glue" - ); - assert_eq!( - ClickPipeKafkaConfluentSchemaRegistryType::Confluent.to_string(), - "confluent" - ); -} - -#[test] -fn kafka_tombstone_mode_is_lossless_and_optional() { - let request = ClickPipePostKafkaSource::default(); - assert!( - serde_json::to_value(request) - .unwrap() - .get("tombstoneMode") - .is_none() - ); - let response: ClickPipeKafkaSource = serde_json::from_value(serde_json::json!({ - "tombstoneMode": "future" - })) - .unwrap(); - assert!( - matches!(response.tombstone_mode, Some(ClickPipeKafkaSourceTombstonemode::Unknown(ref value)) if value == "future") - ); - let request = ClickPipePostKafkaSource { - tombstone_mode: Some(ClickPipePostKafkaSourceTombstonemode::Delete), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(request).unwrap()["tombstoneMode"], - "delete" - ); - assert_eq!( - ClickPipePostKafkaSourceTombstonemode::Delete.to_string(), - "delete" - ); - for wire in [ - serde_json::json!({}), - serde_json::json!({"tombstoneMode": null}), - ] { - let response: ClickPipeKafkaSource = serde_json::from_value(wire).unwrap(); - assert_eq!(response.tombstone_mode, None); - assert!( - serde_json::to_value(response) - .unwrap() - .get("tombstoneMode") - .is_none() - ); - } -} - -#[test] -fn kinesis_sources_omit_missing_or_null_registry() { - let request = ClickPipePostKinesisSource::default(); - assert!( - serde_json::to_value(request) - .unwrap() - .get("schemaRegistry") - .is_none() - ); - for wire in [ - serde_json::json!({}), - serde_json::json!({"schemaRegistry": null}), - ] { - let response: ClickPipeKinesisSource = serde_json::from_value(wire).unwrap(); - assert_eq!(response.schema_registry, None); - assert!( - serde_json::to_value(response) - .unwrap() - .get("schemaRegistry") - .is_none() - ); - } -} - -#[test] -fn query_api_endpoint_request_is_strict_and_omits_optional_fields() { - let required = serde_json::json!({ - "name": "orders", - "sql": "SELECT * FROM orders WHERE id = {id:String}", - "database": "default", - "apiKeyIds": ["11111111-2222-3333-8444-555555555555"], - "roles": ["query_api"] - }); - let request: PublicQueryApiEndpointRequest = serde_json::from_value(required.clone()).unwrap(); - assert_eq!(serde_json::to_value(request).unwrap(), required); - - for field in ["name", "sql", "database", "apiKeyIds", "roles"] { - let mut missing = required.clone(); - missing.as_object_mut().unwrap().remove(field); - assert!( - serde_json::from_value::(missing).is_err(), - "missing required field {field} must fail" - ); - } -} - -#[test] -fn query_api_endpoint_responses_tolerate_missing_and_null_fields() { - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "id": null, - "name": null, - "sql": null, - "database": null, - "parameters": null, - "apiKeyIds": null, - "roles": null, - "allowedOrigins": null, - "url": null, - "ownerType": null - }), - ] { - let endpoint: PublicQueryApiEndpoint = serde_json::from_value(wire).unwrap(); - assert_eq!(endpoint, PublicQueryApiEndpoint::default()); - assert_eq!( - serde_json::to_value(endpoint).unwrap(), - serde_json::json!({}) - ); - } - - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "id": null, - "name": null, - "database": null, - "apiKeyIds": null, - "roles": null, - "allowedOrigins": null, - "url": null, - "ownerType": null - }), - ] { - let item: PublicQueryApiEndpointListItem = serde_json::from_value(wire).unwrap(); - assert_eq!(item, PublicQueryApiEndpointListItem::default()); - assert_eq!(serde_json::to_value(item).unwrap(), serde_json::json!({})); - } - - for wire in [ - serde_json::json!({}), - serde_json::json!({"items": null, "pagination": null}), - ] { - let list: QueryApiEndpointListResponse = serde_json::from_value(wire).unwrap(); - assert_eq!(list, QueryApiEndpointListResponse::default()); - assert_eq!(serde_json::to_value(list).unwrap(), serde_json::json!({})); - } -} - -#[test] -fn query_api_endpoint_list_response_preserves_array_entries_and_envelope_pagination() { - for items in [serde_json::json!([]), serde_json::json!([{}])] { - let response: ApiResponse> = - serde_json::from_value(serde_json::json!({ - "result": items, "limit": 1, "totalCount": 2, - "nextCursor": "opaque +/=&? page", "futureEnvelopeField": true - })) - .unwrap(); - assert_eq!( - serde_json::to_value(response.result.unwrap()).unwrap(), - items - ); - assert_eq!(response.limit, Some(1)); - assert_eq!(response.total_count, Some(2)); - assert_eq!(response.next_cursor.as_deref(), Some("opaque +/=&? page")); - } - for wire in [serde_json::json!({}), serde_json::json!({"result": null})] { - let response: ApiResponse> = - serde_json::from_value(wire).unwrap(); - assert!(response.result.is_none()); - assert_eq!( - serde_json::to_value(response).unwrap(), - serde_json::json!({}) - ); - } -} - -#[test] -fn saved_query_request_is_strict_and_omits_optional_fields() { - let required = serde_json::json!({ - "name": "orders", - "sql": "SELECT * FROM orders WHERE id = {id:String}", - "database": "default" - }); - let request: PublicSavedQueryRequest = serde_json::from_value(required.clone()).unwrap(); - assert_eq!(request.parameters, None); - assert_eq!(serde_json::to_value(&request).unwrap(), required); - - for field in ["name", "sql", "database"] { - let mut missing = required.clone(); - missing.as_object_mut().unwrap().remove(field); - assert!( - serde_json::from_value::(missing).is_err(), - "missing required field {field} must fail" - ); - } - - let maximal = PublicSavedQueryRequest { - parameters: Some(std::collections::BTreeMap::from([( - "id".to_string(), - "42".to_string(), - )])), - ..request - }; - let mut expected = required; - expected["parameters"] = serde_json::json!({"id": "42"}); - assert_eq!(serde_json::to_value(maximal).unwrap(), expected); -} - -#[test] -fn saved_query_responses_tolerate_missing_and_null_fields() { - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "id": null, - "name": null, - "sql": null, - "database": null, - "parameters": null - }), - ] { - let query: PublicSavedQuery = serde_json::from_value(wire).unwrap(); - assert_eq!(query, PublicSavedQuery::default()); - assert_eq!(serde_json::to_value(query).unwrap(), serde_json::json!({})); - } - - for wire in [ - serde_json::json!({}), - serde_json::json!({"id": null, "name": null, "database": null}), - ] { - let item: PublicSavedQueryListItem = serde_json::from_value(wire).unwrap(); - assert_eq!(item, PublicSavedQueryListItem::default()); - assert_eq!(serde_json::to_value(item).unwrap(), serde_json::json!({})); - } -} - -#[test] -fn saved_query_response_round_trips_every_field_and_ignores_unknown_keys() { - let wire = serde_json::json!({ - "id": "11111111-2222-3333-8444-555555555555", - "name": "orders", - "sql": "SELECT {id:String}", - "database": "default", - "parameters": {"id": "42"} - }); - let mut with_unknown = wire.clone(); - with_unknown["futureField"] = serde_json::json!(true); - let query: PublicSavedQuery = serde_json::from_value(with_unknown).unwrap(); - assert_eq!(serde_json::to_value(query).unwrap(), wire); - - let item_wire = serde_json::json!({ - "id": "11111111-2222-3333-8444-555555555555", - "name": "orders", - "database": "default" - }); - let item: PublicSavedQueryListItem = serde_json::from_value(item_wire.clone()).unwrap(); - assert_eq!(serde_json::to_value(item).unwrap(), item_wire); -} - -#[test] -fn query_api_endpoint_owner_types_preserve_unknown_values() { - let owner: PublicQueryApiEndpointOwnertype = serde_json::from_str("\"futureOwner\"").unwrap(); - assert_eq!( - owner, - PublicQueryApiEndpointOwnertype::Unknown("futureOwner".into()) - ); - assert_eq!(owner.to_string(), "futureOwner"); - assert_eq!(serde_json::to_string(&owner).unwrap(), "\"futureOwner\""); - - let list_owner: PublicQueryApiEndpointListItemOwnertype = - serde_json::from_str("\"futureOwner\"").unwrap(); - assert_eq!( - list_owner, - PublicQueryApiEndpointListItemOwnertype::Unknown("futureOwner".into()) - ); - assert_eq!(list_owner.to_string(), "futureOwner"); - assert_eq!( - serde_json::to_string(&list_owner).unwrap(), - "\"futureOwner\"" - ); -} - -#[test] -fn api_response_pagination_tolerates_missing_null_and_unknown_fields() { - for value in [ - serde_json::json!({}), - serde_json::json!({ - "limit": null, "totalCount": null, "nextCursor": null, "futureMetadata": true - }), - ] { - let response: ApiResponse> = serde_json::from_value(value).unwrap(); - assert_eq!(response.limit, None); - assert_eq!(response.total_count, None); - assert_eq!(response.next_cursor, None); - assert_eq!( - serde_json::to_value(response).unwrap(), - serde_json::json!({}) - ); - } -} - -#[test] -fn api_response_pagination_round_trips_values_and_omits_last_page_cursor() { - for (total_count, next_cursor) in [(0, None), (500, Some("opaque +/=&?雪"))] { - let mut value = serde_json::json!({ - "status": 200, "requestId": "req-page", "result": [], - "limit": 250, "totalCount": total_count - }); - if let Some(cursor) = next_cursor { - value["nextCursor"] = serde_json::json!(cursor); - } - let response: ApiResponse> = serde_json::from_value(value.clone()).unwrap(); - assert_eq!(response.limit, Some(250)); - assert_eq!(response.total_count, Some(total_count)); - assert_eq!(response.next_cursor.as_deref(), next_cursor); - assert_eq!(serde_json::to_value(response).unwrap(), value); - } -} - -#[test] -fn activity_type_approved_domain_and_org_update_values_round_trip() { - let cases = [ - ( - "organization_approved_domain_auto_invite", - ActivityType::Organization_approved_domain_auto_invite, - ), - ( - "organization_approved_domain_auto_join", - ActivityType::Organization_approved_domain_auto_join, - ), - ( - "organization_approved_domain_delete", - ActivityType::Organization_approved_domain_delete, - ), - ( - "organization_approved_domain_update", - ActivityType::Organization_approved_domain_update, - ), - ( - "organization_approved_domain_verify", - ActivityType::Organization_approved_domain_verify, - ), - ( - "organization_update_public_preview", - ActivityType::Organization_update_public_preview, - ), - ( - "organization_update_saml_query_ownership_migration", - ActivityType::Organization_update_saml_query_ownership_migration, - ), - ]; - for (wire, expected) in cases { - let parsed: ActivityType = serde_json::from_str(&format!("\"{wire}\"")).unwrap(); - assert_eq!(parsed, expected, "{wire} should not be Unknown"); - assert_eq!(parsed.to_string(), wire); - assert_eq!(serde_json::to_value(&parsed).unwrap(), wire); - } -} - -#[test] -fn byoc_config_state_new_values_round_trip() { - let cases = [ - ("infra-degraded", ByocConfigState::Infra_degraded), - ("infra-terminating", ByocConfigState::Infra_terminating), - ("infra-upgrading", ByocConfigState::Infra_upgrading), - ]; - for (wire, expected) in cases { - let parsed: ByocConfigState = serde_json::from_str(&format!("\"{wire}\"")).unwrap(); - assert_eq!(parsed, expected, "{wire} should not be Unknown"); - assert_eq!(parsed.to_string(), wire); - assert_eq!(serde_json::to_value(&parsed).unwrap(), wire); - } -} - -#[test] -fn kafka_tombstone_mode_soft_delete_round_trips() { - let parsed: ClickPipeKafkaSourceTombstonemode = - serde_json::from_str(r#""soft_delete""#).unwrap(); - assert_eq!(parsed, ClickPipeKafkaSourceTombstonemode::Soft_delete); - assert_eq!(parsed.to_string(), "soft_delete"); - assert_eq!(serde_json::to_value(&parsed).unwrap(), "soft_delete"); - - let parsed: ClickPipePostKafkaSourceTombstonemode = - serde_json::from_str(r#""soft_delete""#).unwrap(); - assert_eq!(parsed, ClickPipePostKafkaSourceTombstonemode::Soft_delete); - assert_eq!(parsed.to_string(), "soft_delete"); - let request = ClickPipePostKafkaSource { - tombstone_mode: Some(ClickPipePostKafkaSourceTombstonemode::Soft_delete), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(request).unwrap()["tombstoneMode"], - "soft_delete" - ); -} - -#[test] -fn byoc_config_account_id_deserializes_and_tolerates_absence() { - let config: ByocConfig = serde_json::from_value(serde_json::json!({ - "accountId": "123456789012", - "state": "infra-ready" - })) - .unwrap(); - assert_eq!(config.account_id.as_deref(), Some("123456789012")); - assert_eq!( - serde_json::to_value(&config).unwrap()["accountId"], - "123456789012" - ); - - for wire in [ - serde_json::json!({}), - serde_json::json!({ "accountId": null }), - ] { - let config: ByocConfig = serde_json::from_value(wire).unwrap(); - assert_eq!(config.account_id, None); - assert!( - serde_json::to_value(&config) - .unwrap() - .get("accountId") - .is_none() - ); - } -} - -#[cfg(not(feature = "deprecated-fields"))] -#[test] -fn byoc_config_deprecated_account_name_is_hidden_by_default() { - let config: ByocConfig = serde_json::from_value(serde_json::json!({ - "accountId": "123456789012", - "accountName": "123456789012" - })) - .unwrap(); - assert!( - serde_json::to_value(&config) - .unwrap() - .get("accountName") - .is_none() - ); -} - -#[test] -fn activity_type_postgres_action_round_trips() { - let parsed: ActivityType = serde_json::from_str("\"postgres_action\"").unwrap(); - assert_eq!(parsed, ActivityType::Postgres_action); - assert_eq!(parsed.to_string(), "postgres_action"); - assert_eq!(serde_json::to_value(&parsed).unwrap(), "postgres_action"); -} - -fn sample_backup_encryption_config() -> serde_json::Value { - serde_json::json!({ - "schema_version": 1, - "restore_key_pairs": [ - { - "customer_managed_encryption_key": { - "aws_kms_key_arn": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" - }, - "encrypted_dek": "d3JhcHBlZA==", - "future_field": null - } - ], - "provider_extension": {"nested": [1, 2.5, true, "x"]} - }) -} - -#[test] -fn backup_encryption_config_round_trips_opaque_nested_contents() { - let file = sample_backup_encryption_config(); - let config: BackupEncryptionConfig = serde_json::from_value(file.clone()).unwrap(); - assert_eq!(config["schema_version"], 1); - assert_eq!( - config["restore_key_pairs"][0]["customer_managed_encryption_key"]["aws_kms_key_arn"], - "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" - ); - assert_eq!(serde_json::to_value(&config).unwrap(), file); - - // The file is an object; a non-object is not a valid config. - assert!(serde_json::from_value::(serde_json::json!([1])).is_err()); -} - -#[test] -fn service_post_request_backup_encryption_config_serialization() { - let base = ServicePostRequest { - name: "restored".to_string(), - provider: ServicePostRequestProvider::Aws, - region: ServicePostRequestRegion::Us_east_1, - backup_id: Some(uuid::Uuid::nil()), - ..Default::default() - }; - let json = serde_json::to_value(&base).unwrap(); - assert!(json.get("backupEncryptionConfig").is_none()); - - let file = sample_backup_encryption_config(); - let req = ServicePostRequest { - backup_encryption_config: Some(serde_json::from_value(file.clone()).unwrap()), - ..base - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["backupEncryptionConfig"], file); - assert_eq!(json["backupId"], uuid::Uuid::nil().to_string()); - - let parsed: ServicePostRequest = serde_json::from_value(json).unwrap(); - assert_eq!(parsed, req); -} - -#[test] -fn postgres_backup_tolerates_missing_and_null_fields() { - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "key": null, "lastModified": null, "status": null, "futureField": 1 - }), - ] { - let backup: PostgresBackup = serde_json::from_value(wire).unwrap(); - assert_eq!(backup, PostgresBackup::default()); - assert_eq!(serde_json::to_value(backup).unwrap(), serde_json::json!({})); - } -} - -#[test] -fn postgres_backup_round_trips_every_field() { - let wire = serde_json::json!({ - "key": "basebackups_005/000000010000000000000002_backup_stop_sentinel.json", - "lastModified": "2026-03-31T18:17:37Z", - "status": "ready" - }); - let backup: PostgresBackup = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!( - backup.key.as_deref(), - Some("basebackups_005/000000010000000000000002_backup_stop_sentinel.json") - ); - assert!(backup.last_modified.is_some()); - assert_eq!(backup.status.as_deref(), Some("ready")); - assert_eq!(serde_json::to_value(backup).unwrap(), wire); -} - -#[test] -fn postgres_service_access_list_and_restore_time_tolerate_missing_and_null() { - for wire in [ - serde_json::json!({}), - serde_json::json!({"ipAccessList": null, "earliestRestoreTime": null}), - ] { - let pg: PostgresService = serde_json::from_value(wire).unwrap(); - assert_eq!(pg.ip_access_list, None); - assert_eq!(pg.earliest_restore_time, None); - assert_eq!(serde_json::to_value(pg).unwrap(), serde_json::json!({})); - } -} - -#[test] -fn postgres_service_round_trips_access_list_and_restore_time() { - let wire = serde_json::json!({ - "earliestRestoreTime": "2026-03-31T18:17:37Z", - "ipAccessList": [ - { - "id": "rule-1", - "source": "203.0.113.0/24", - "portRange": "5432", - "description": "office" - }, - {"source": "198.51.100.7/32", "portRange": "5432..6432"} - ] - }); - let pg: PostgresService = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!( - pg.earliest_restore_time, - Some("2026-03-31T18:17:37Z".parse().unwrap()) - ); - let rules = pg.ip_access_list.as_ref().unwrap(); - assert_eq!(rules.len(), 2); - assert_eq!(rules[0].id.as_deref(), Some("rule-1")); - assert_eq!(rules[0].port_range.as_deref(), Some("5432")); - assert_eq!(rules[1].description, None); - assert_eq!(serde_json::to_value(pg).unwrap(), wire); -} - -#[test] -fn postgres_ip_access_list_entry_response_tolerates_missing_and_null() { - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "id": null, "source": null, "portRange": null, "description": null, - "futureField": 1 - }), - ] { - let entry: PostgresIpAccessListEntryResponse = serde_json::from_value(wire).unwrap(); - assert_eq!(entry, PostgresIpAccessListEntryResponse::default()); - assert_eq!(serde_json::to_value(entry).unwrap(), serde_json::json!({})); - } -} - -#[test] -fn postgres_ip_access_list_request_types_are_strict() { - // `source` and `portRange` are required on a rule, `id` on a rule - // reference, and both lists on a patch: a body missing one is rejected - // rather than silently defaulted. - assert!(serde_json::from_str::("{}").is_err()); - assert!( - serde_json::from_str::(r#"{"source":"203.0.113.0/24"}"#) - .is_err() - ); - assert!(serde_json::from_str::(r#"{"portRange":"5432"}"#).is_err()); - assert!(serde_json::from_str::("{}").is_err()); - assert!(serde_json::from_str::(r#"{"add":[]}"#).is_err()); - assert!(serde_json::from_str::(r#"{"remove":[]}"#).is_err()); - - // A new rule omits `id` and `description`. - let entry = PostgresIpAccessListEntry { - source: "203.0.113.0/24".to_string(), - port_range: "5432".to_string(), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&entry).unwrap(), - serde_json::json!({"source": "203.0.113.0/24", "portRange": "5432"}) - ); -} - -#[test] -fn postgres_ip_access_list_entry_response_converts_back_into_a_request_entry() { - let entry = PostgresIpAccessListEntry::try_from(PostgresIpAccessListEntryResponse { - id: Some("rule-1".to_string()), - source: Some("203.0.113.0/24".to_string()), - port_range: Some("6432".to_string()), - description: Some("office".to_string()), - }) - .unwrap(); - // The returned `id` survives, so writing the rule back edits it. - assert_eq!( - serde_json::to_value(&entry).unwrap(), - serde_json::json!({ - "id": "rule-1", "source": "203.0.113.0/24", "portRange": "6432", - "description": "office" - }) - ); - - let missing_port = PostgresIpAccessListEntry::try_from(PostgresIpAccessListEntryResponse { - source: Some("203.0.113.0/24".to_string()), - ..Default::default() - }) - .unwrap_err(); - assert_eq!(missing_port.fields(), ["portRange"]); - - let missing_both = - PostgresIpAccessListEntry::try_from(PostgresIpAccessListEntryResponse::default()) - .unwrap_err(); - assert_eq!(missing_both.fields(), ["portRange", "source"]); -} - -#[test] -fn postgres_service_post_request_distinguishes_omitted_and_empty_access_list() { - let base = PostgresServicePostRequest { - name: "pg-new".to_string(), - provider: PgProvider::Aws, - region: "us-east-1".to_string(), - size: PgSize::C6gd_large, - ..Default::default() - }; - // Omitted allows all IPv4 and IPv6 addresses. - let omitted = serde_json::to_value(&base).unwrap(); - assert!(omitted.get("ipAccessList").is_none(), "{omitted}"); - - // An empty list blocks public access, so it must reach the wire as `[]`. - let blocked = PostgresServicePostRequest { - ip_access_list: Some(Vec::new()), - ..base.clone() - }; - let blocked = serde_json::to_value(&blocked).unwrap(); - assert_eq!(blocked["ipAccessList"], serde_json::json!([])); - - let allowed = PostgresServicePostRequest { - ip_access_list: Some(vec![PostgresIpAccessListEntry { - source: "203.0.113.0/24".to_string(), - port_range: "5432".to_string(), - ..Default::default() - }]), - ..base - }; - assert_eq!( - serde_json::to_value(&allowed).unwrap()["ipAccessList"], - serde_json::json!([{"source": "203.0.113.0/24", "portRange": "5432"}]) - ); -} - -#[test] -fn postgres_service_patch_request_serializes_the_access_list_patch() { - let untouched = PostgresServicePatchRequest::default(); - assert_eq!( - serde_json::to_value(&untouched).unwrap(), - serde_json::json!({}) - ); - - let patch = PostgresServicePatchRequest { - ip_access_list: Some(PostgresIpAccessListPatch { - add: vec![PostgresIpAccessListEntry { - id: Some("rule-1".to_string()), - source: "203.0.113.0/24".to_string(), - port_range: "6432".to_string(), - description: None, - }], - remove: vec![PostgresIpAccessListRuleReference { - id: "rule-2".to_string(), - }], - }), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&patch).unwrap(), - serde_json::json!({ - "ipAccessList": { - "add": [{"id": "rule-1", "source": "203.0.113.0/24", "portRange": "6432"}], - "remove": [{"id": "rule-2"}] - } - }) - ); -} - -#[test] -fn whoami_variants_tolerate_missing_and_null_fields() { - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "actorType": null, "userId": null, "email": null, "name": null, - "organizations": null - }), - ] { - let user: WhoamiUser = serde_json::from_value(wire).unwrap(); - assert_eq!(user, WhoamiUser::default()); - assert_eq!(serde_json::to_value(user).unwrap(), serde_json::json!({})); - } - for wire in [ - serde_json::json!({}), - serde_json::json!({ - "actorType": null, "keyId": null, "name": null, "organizationId": null - }), - ] { - let key: WhoamiApiKey = serde_json::from_value(wire).unwrap(); - assert_eq!(key, WhoamiApiKey::default()); - assert_eq!(serde_json::to_value(key).unwrap(), serde_json::json!({})); - } - for wire in [ - serde_json::json!({}), - serde_json::json!({"organizationId": null, "organizationName": null}), - ] { - let org: WhoamiOrganization = serde_json::from_value(wire).unwrap(); - assert_eq!(org, WhoamiOrganization::default()); - assert_eq!(serde_json::to_value(org).unwrap(), serde_json::json!({})); - } -} - -#[test] -fn whoami_dispatches_on_actor_type_and_tolerates_sparse_variants() { - let user: Whoami = serde_json::from_value(serde_json::json!({"actorType": "user"})).unwrap(); - assert_eq!( - user, - Whoami::WhoamiUser(WhoamiUser { - actor_type: Some(WhoamiUserActortype::User), - ..Default::default() - }) - ); - assert_eq!(user.to_string(), "WhoamiUser"); - - // `name` is shared by both variants; dispatch must follow `actorType`. - let key: Whoami = serde_json::from_value(serde_json::json!({ - "actorType": "apiKey", "name": "ci key", "organizations": null - })) - .unwrap(); - assert_eq!( - key, - Whoami::WhoamiApiKey(WhoamiApiKey { - actor_type: Some(WhoamiApiKeyActortype::ApiKey), - name: Some("ci key".into()), - ..Default::default() - }) - ); - assert_eq!(key.to_string(), "WhoamiApiKey"); -} - -#[test] -fn whoami_keeps_unknown_absent_or_malformed_actor_types_verbatim() { - for wire in [ - serde_json::json!({"actorType": "serviceAccount", "name": "sa"}), - serde_json::json!({"name": "no discriminator"}), - serde_json::json!({"actorType": null, "keyId": "key-1"}), - // Recognized discriminator whose payload no longer fits the variant. - serde_json::json!({"actorType": "user", "organizations": "not-a-list"}), - ] { - let whoami: Whoami = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(whoami, Whoami::Unknown(wire.clone())); - assert_eq!(serde_json::to_value(&whoami).unwrap(), wire); - assert_eq!(whoami.to_string(), wire.to_string()); - } -} - -#[test] -fn whoami_actor_types_preserve_unknown_values() { - let user: WhoamiUserActortype = serde_json::from_str("\"futureUser\"").unwrap(); - assert_eq!(user, WhoamiUserActortype::Unknown("futureUser".into())); - assert_eq!(user.to_string(), "futureUser"); - assert_eq!(WhoamiUserActortype::User.to_string(), "user"); - let key: WhoamiApiKeyActortype = serde_json::from_str("\"futureKey\"").unwrap(); - assert_eq!(key, WhoamiApiKeyActortype::Unknown("futureKey".into())); - assert_eq!(WhoamiApiKeyActortype::ApiKey.to_string(), "apiKey"); -} - -#[test] -fn notification_type_missing_and_null_fields_are_none() { - let missing: NotificationType = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, NotificationType::default()); - assert_eq!( - serde_json::to_value(&missing).unwrap(), - serde_json::json!({}) - ); - - let null: NotificationType = serde_json::from_value(serde_json::json!({ - "name": null, "title": null, "description": null, "category": null, - "severity": null, "resourceType": null, "isConfigurable": null, - "isAvailable": null, "futureField": 1 - })) - .unwrap(); - assert_eq!(null, NotificationType::default()); -} - -#[test] -fn notification_type_severity_round_trips_and_preserves_unknown() { - for (wire, value) in [ - ("info", NotificationTypeSeverity::Info), - ("warning", NotificationTypeSeverity::Warning), - ("critical", NotificationTypeSeverity::Critical), - ] { - let parsed: NotificationTypeSeverity = - serde_json::from_value(serde_json::json!(wire)).unwrap(); - assert_eq!(parsed, value); - assert_eq!(value.to_string(), wire); - assert_eq!( - serde_json::to_value(&value).unwrap(), - serde_json::json!(wire) - ); - } - let unknown: NotificationTypeSeverity = - serde_json::from_value(serde_json::json!("emergency")).unwrap(); - assert_eq!( - unknown, - NotificationTypeSeverity::Unknown("emergency".to_string()) - ); - assert_eq!(unknown.to_string(), "emergency"); -} - -#[test] -fn replica_aware_routing_missing_and_null_fields_are_none() { - let missing: ReplicaAwareRouting = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, ReplicaAwareRouting::default()); - assert_eq!( - serde_json::to_value(&missing).unwrap(), - serde_json::json!({}) - ); - - let null: ReplicaAwareRouting = serde_json::from_value(serde_json::json!({ - "httpEnabled": null, "nativeEnabled": null - })) - .unwrap(); - assert_eq!(null, ReplicaAwareRouting::default()); -} - -#[test] -fn serialize_replica_aware_routing_patch_request() { - assert_eq!( - serde_json::to_value(ReplicaAwareRoutingPatchRequest::default()).unwrap(), - serde_json::json!({}) - ); - assert_eq!( - serde_json::to_value(ReplicaAwareRoutingPatchRequest { - http_enabled: Some(false), - native_enabled: None, - }) - .unwrap(), - serde_json::json!({"httpEnabled": false}) - ); - assert_eq!( - serde_json::to_value(ReplicaAwareRoutingPatchRequest { - http_enabled: Some(true), - native_enabled: Some(false), - }) - .unwrap(), - serde_json::json!({"httpEnabled": true, "nativeEnabled": false}) - ); -} - -#[test] -fn prometheus_discovery_labels_keep_named_meta_and_additional_labels() { - let json = serde_json::json!({ - "__scheme__": "https", - "__meta_clickhouse_provider": "aws", - "__meta_clickhouse_region": "us-east-1", - "clickhouse_discovery_service_name": "analytics", - "clickhouse_custom_label": "blue" - }); - // An unknown non-string key is ignored rather than failing the response. - let mut with_unknown = json.clone(); - with_unknown["future_flag"] = serde_json::json!(true); - let tolerant: PrometheusDiscoveryLabels = serde_json::from_value(with_unknown).unwrap(); - assert_eq!(serde_json::to_value(&tolerant).unwrap(), json); - - let labels: PrometheusDiscoveryLabels = serde_json::from_value(json.clone()).unwrap(); - - assert_eq!(labels.meta_clickhouse_provider.as_deref(), Some("aws")); - assert_eq!(labels.meta_clickhouse_region.as_deref(), Some("us-east-1")); - assert_eq!( - labels.additional_labels, - Some(std::collections::BTreeMap::from([( - "clickhouse_custom_label".to_string(), - "blue".to_string() - )])) - ); - // Named labels are not duplicated into the additional map, and the - // round trip is lossless. - assert_eq!(serde_json::to_value(&labels).unwrap(), json); -} - -#[test] -fn prometheus_discovery_labels_without_additional_labels_serialize_compactly() { - let labels: PrometheusDiscoveryLabels = - serde_json::from_str(r#"{"__meta_clickhouse_region":null}"#).unwrap(); - assert_eq!(labels.meta_clickhouse_region, None); - assert_eq!( - serde_json::to_value(&labels).unwrap(), - serde_json::json!({}) - ); -} - -#[test] -fn pg_config_carries_new_gucs_through_write_back() { - let response: PgConfigResponse = serde_json::from_value(serde_json::json!({ - "autovacuum_analyze_threshold": 50, - "autovacuum_freeze_max_age": 200000000, - "autovacuum_vacuum_insert_threshold": -1, - "autovacuum_vacuum_threshold": 50, - "checkpoint_timeout": "5min", - "shared_memory_percent": 25, - "wal_buffers": "16MB" - })) - .unwrap(); - let missing: PgConfigResponse = serde_json::from_str( - r#"{"autovacuum_analyze_threshold":null,"checkpoint_timeout":null,"wal_buffers":null}"#, - ) - .unwrap(); - assert_eq!(missing, PgConfigResponse::default()); - - let request = PgConfig::from(response); - assert_eq!( - serde_json::to_value(&request).unwrap(), - serde_json::json!({ - "autovacuum_analyze_threshold": 50, - "autovacuum_freeze_max_age": 200000000, - "autovacuum_vacuum_insert_threshold": -1, - "autovacuum_vacuum_threshold": 50, - "checkpoint_timeout": "5min", - "shared_memory_percent": 25, - "wal_buffers": "16MB" - }) - ); -} - -#[test] -fn pg_config_carries_io_logging_and_replication_gucs_through_write_back() { - let wire = serde_json::json!({ - "bgwriter_delay": "200ms", - "bgwriter_flush_after": "512kB", - "bgwriter_lru_maxpages": 100, - "bgwriter_lru_multiplier": 2, - "default_toast_compression": "lz4", - "enable_parallel_hash": "off", - "hash_mem_multiplier": 2, - "io_method": "io_uring", - "io_workers": 3, - "log_lock_waits": "on", - "log_min_duration_statement": 1000, - "log_parameter_max_length": "-1", - "log_temp_files": -1, - "logical_decoding_work_mem": "64MB", - "maintenance_io_concurrency": 10, - "max_logical_replication_workers": 4, - "max_replication_slots": 10, - "max_sync_workers_per_subscription": 2, - "max_wal_senders": 10, - "temp_buffers": "8MB", - "track_activity_query_size": 4096, - "vacuum_cost_delay": "0" - }); - let response: PgConfigResponse = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!( - response.default_toast_compression, - Some(PgConfigDefaultToastCompression::Lz4) - ); - assert_eq!( - response.enable_parallel_hash, - Some(PgConfigEnableParallelHash::Off) - ); - assert_eq!(response.io_method, Some(PgConfigIoMethod::Io_uring)); - assert_eq!(response.log_lock_waits, Some(PgConfigLogLockWaits::On)); - assert_eq!(response.log_temp_files, Some(serde_json::json!(-1))); - - let missing: PgConfigResponse = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, PgConfigResponse::default()); - let null_fields = wire - .as_object() - .unwrap() - .keys() - .map(|key| (key.clone(), serde_json::Value::Null)) - .collect::>(); - let nulls: PgConfigResponse = - serde_json::from_value(serde_json::Value::Object(null_fields)).unwrap(); - assert_eq!(nulls, PgConfigResponse::default()); - - // Write-back is lossless: every new GUC survives the response -> request - // conversion with its wire name and value unchanged. - let request = PgConfig::from(response); - assert_eq!(serde_json::to_value(&request).unwrap(), wire); -} - -#[test] -fn pg_config_serializes_only_the_new_gucs_that_are_set() { - let config = PgConfig { - io_method: Some(PgConfigIoMethod::Worker), - io_workers: Some(serde_json::json!(8)), - log_min_duration_statement: Some(serde_json::json!("250ms")), - max_wal_senders: Some(serde_json::json!(20)), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&config).unwrap(), - serde_json::json!({ - "io_method": "worker", - "io_workers": 8, - "log_min_duration_statement": "250ms", - "max_wal_senders": 20 - }) - ); -} - -#[test] -fn database_clickpipe_sources_accept_workload_identity_and_cloud_sql_mysql() { - macro_rules! assert_round_trip { - ($ty:ty, $variant:expr, $wire:literal) => {{ - let parsed: $ty = serde_json::from_str(concat!('"', $wire, '"')).unwrap(); - assert_eq!(parsed, $variant); - assert_eq!(parsed.to_string(), $wire); - assert_eq!( - serde_json::to_string(&parsed).unwrap(), - concat!('"', $wire, '"') - ); - }}; - } - - assert_round_trip!( - ClickPipeMutateMySQLSourceAuthentication, - ClickPipeMutateMySQLSourceAuthentication::ServiceAccountWorkloadIdentity, - "SERVICE_ACCOUNT_WORKLOAD_IDENTITY" - ); - assert_round_trip!( - ClickPipeMySQLSourceAuthentication, - ClickPipeMySQLSourceAuthentication::ServiceAccountWorkloadIdentity, - "SERVICE_ACCOUNT_WORKLOAD_IDENTITY" - ); - assert_round_trip!( - ClickPipePatchMySQLSourceAuthentication, - ClickPipePatchMySQLSourceAuthentication::ServiceAccountWorkloadIdentity, - "SERVICE_ACCOUNT_WORKLOAD_IDENTITY" - ); - assert_round_trip!( - ClickPipeMutatePostgresSourceAuthentication, - ClickPipeMutatePostgresSourceAuthentication::ServiceAccountWorkloadIdentity, - "SERVICE_ACCOUNT_WORKLOAD_IDENTITY" - ); - assert_round_trip!( - ClickPipePostgresSourceAuthentication, - ClickPipePostgresSourceAuthentication::ServiceAccountWorkloadIdentity, - "SERVICE_ACCOUNT_WORKLOAD_IDENTITY" - ); - assert_round_trip!( - ClickPipeMutateMySQLSourceType, - ClickPipeMutateMySQLSourceType::Cloudsqlmysql, - "cloudsqlmysql" - ); - assert_round_trip!( - ClickPipeMySQLSourceType, - ClickPipeMySQLSourceType::Cloudsqlmysql, - "cloudsqlmysql" - ); - assert!( - ClickPipeMutateMySQLSourceAuthentication::VALUES - .contains(&"SERVICE_ACCOUNT_WORKLOAD_IDENTITY") - ); - assert!(ClickPipeMutateMySQLSourceType::VALUES.contains(&"cloudsqlmysql")); -} - -#[test] -fn mongodb_structured_ingestion_request_is_strict_and_omits_unset_fields() { - let minimal = ClickPipeMongoDBPipeTableMapping { - source_database_name: "mydb".to_string(), - source_collection: "users".to_string(), - target_table: "mydb_users".to_string(), - structured_ingestion: Some(ClickPipeMongoDBStructuredIngestion { - enabled: true, - ..Default::default() - }), - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&minimal).unwrap(), - serde_json::json!({ - "sourceDatabaseName": "mydb", - "sourceCollection": "users", - "targetTable": "mydb_users", - "structuredIngestion": { "enabled": true } - }) - ); - - let maximal: ClickPipeMongoDBStructuredIngestion = serde_json::from_value(serde_json::json!({ - "enabled": true, - "dropUnexpectedValues": false, - "columns": [{ "name": "amount", "type": "Float64" }], - "excludedColumns": ["internal_notes"], - "sortingKeys": ["_id", "created_at"], - "partitionByExpr": "toYYYYMM(created_at)" - })) - .unwrap(); - assert_eq!( - maximal.columns, - Some(vec![ClickPipeMongoDBPipeColumn { - name: "amount".to_string(), - r#type: "Float64".to_string(), - }]) - ); - assert_eq!( - maximal.partition_by_expr.as_deref(), - Some("toYYYYMM(created_at)") - ); - - // `enabled` and both column fields are required in request position. - assert!(serde_json::from_str::("{}").is_err()); - assert!(serde_json::from_str::(r#"{"name":"amount"}"#).is_err()); -} - -#[test] -fn mongodb_structured_ingestion_response_tolerates_absent_and_null_fields() { - let mapping: ClickPipeMongoDBPipeTableMappingResponse = serde_json::from_str( - r#"{"sourceDatabaseName":"mydb","structuredIngestion":{"enabled":null,"columns":[{"name":null}],"sortingKeys":null}}"#, - ) - .unwrap(); - let ingestion = mapping.structured_ingestion.unwrap(); - assert_eq!(ingestion.enabled, None); - assert_eq!( - ingestion.columns, - Some(vec![ClickPipeMongoDBPipeColumnResponse::default()]) - ); - assert_eq!( - serde_json::from_str::("{}").unwrap(), - ClickPipeMongoDBStructuredIngestionResponse::default() - ); - assert_eq!( - serde_json::to_value(ClickPipeMongoDBStructuredIngestionResponse::default()).unwrap(), - serde_json::json!({}) - ); -} - -#[test] -fn mongodb_table_config_request_is_strict_and_round_trips() { - let mapping = ClickPipeMongoDBPipeTableMapping { - source_database_name: "mydb".to_string(), - source_collection: "users".to_string(), - target_table: "mydb_users".to_string(), - mongo_config: Some(ClickPipeMongoDBTableConfig { - persist_deleted_documents: true, - }), - ..Default::default() - }; - let wire = serde_json::json!({ - "sourceDatabaseName": "mydb", - "sourceCollection": "users", - "targetTable": "mydb_users", - "mongoConfig": { "persistDeletedDocuments": true } - }); - assert_eq!(serde_json::to_value(&mapping).unwrap(), wire); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - mapping - ); - - // `persistDeletedDocuments` is required in request position, so an - // explicit `false` is sent and an empty config is rejected. - assert_eq!( - serde_json::to_value(ClickPipeMongoDBTableConfig::default()).unwrap(), - serde_json::json!({ "persistDeletedDocuments": false }) - ); - assert!(serde_json::from_str::("{}").is_err()); - - // The mapping itself omits an unset `mongoConfig`. - let unset = ClickPipeMongoDBPipeTableMapping { - mongo_config: None, - ..mapping - }; - assert!( - serde_json::to_value(&unset) - .unwrap() - .get("mongoConfig") - .is_none() - ); -} - -#[test] -fn mongodb_table_config_response_tolerates_absent_and_null_fields() { - for raw in [ - r#"{"sourceDatabaseName":"mydb"}"#, - r#"{"sourceDatabaseName":"mydb","mongoConfig":null}"#, - ] { - let mapping: ClickPipeMongoDBPipeTableMappingResponse = serde_json::from_str(raw).unwrap(); - assert_eq!(mapping.mongo_config, None); - } - for raw in [ - r#"{"mongoConfig":{}}"#, - r#"{"mongoConfig":{"persistDeletedDocuments":null}}"#, - ] { - let mapping: ClickPipeMongoDBPipeTableMappingResponse = serde_json::from_str(raw).unwrap(); - assert_eq!( - mapping.mongo_config, - Some(ClickPipeMongoDBTableConfigResponse::default()) - ); - } - let mapping: ClickPipeMongoDBPipeTableMappingResponse = - serde_json::from_str(r#"{"mongoConfig":{"persistDeletedDocuments":false}}"#).unwrap(); - assert_eq!( - mapping.mongo_config.unwrap().persist_deleted_documents, - Some(false) - ); - assert_eq!( - serde_json::to_value(ClickPipeMongoDBTableConfigResponse::default()).unwrap(), - serde_json::json!({}) - ); -} - -#[test] -fn bigquery_cdc_fields_round_trip_and_write_back() { - let settings = serde_json::json!({ - "replicationMode": "cdc", "replicationMethod": "query_based", - "syncIntervalSeconds": 60, "queryCdcPullSyncParallelism": 4, - "syncDelaySeconds": 0, "pullWindowSizeSeconds": 86400 - }); - let response: ClickPipeBigQueryPipeSettingsResponse = - serde_json::from_value(settings.clone()).unwrap(); - let request = ClickPipeBigQueryPipeSettings::try_from(response).unwrap(); - assert_eq!(request.replication_method.as_deref(), Some("query_based")); - assert_eq!(request.sync_interval_seconds, Some(60)); - assert_eq!(request.query_cdc_pull_sync_parallelism, Some(4)); - assert_eq!(request.sync_delay_seconds, Some(0)); - assert_eq!(request.pull_window_size_seconds, Some(86400)); - assert_eq!(serde_json::to_value(&request).unwrap(), settings); - assert_eq!( - serde_json::from_value::(settings).unwrap(), - request - ); - - let mapping = serde_json::json!({ - "sourceDatasetName": "sales", "sourceTable": "orders", "targetTable": "orders_raw", - "queryCdcWatermarkColumn": "updated_at", "eventsFunction": "future_function" - }); - let response: ClickPipeBigQueryPipeTableMappingResponse = - serde_json::from_value(mapping.clone()).unwrap(); - let request = ClickPipeBigQueryPipeTableMapping::try_from(response).unwrap(); - assert_eq!( - request.query_cdc_watermark_column.as_deref(), - Some("updated_at") - ); - assert_eq!(request.events_function.as_deref(), Some("future_function")); - assert_eq!(serde_json::to_value(request).unwrap(), mapping); -} - -#[test] -fn bigquery_cdc_response_fields_tolerate_absence_and_null() { - for value in [ - serde_json::json!({}), - serde_json::json!({ - "replicationMode": null, "replicationMethod": null, "syncIntervalSeconds": null, - "queryCdcPullSyncParallelism": null, "syncDelaySeconds": null, "pullWindowSizeSeconds": null - }), - ] { - let response: ClickPipeBigQueryPipeSettingsResponse = - serde_json::from_value(value).unwrap(); - assert_eq!(response, ClickPipeBigQueryPipeSettingsResponse::default()); - assert_eq!( - serde_json::to_value(&response).unwrap(), - serde_json::json!({}) - ); - assert_eq!( - ClickPipeBigQueryPipeSettings::try_from(response) - .unwrap_err() - .fields(), - &["replicationMode"] - ); - } - for value in [ - serde_json::json!({}), - serde_json::json!({ - "eventsFunction": null, "queryCdcWatermarkColumn": null - }), - ] { - let response: ClickPipeBigQueryPipeTableMappingResponse = - serde_json::from_value(value).unwrap(); - assert_eq!( - response, - ClickPipeBigQueryPipeTableMappingResponse::default() - ); - assert_eq!( - serde_json::to_value(&response).unwrap(), - serde_json::json!({}) - ); - assert_eq!( - ClickPipeBigQueryPipeTableMapping::try_from(response) - .unwrap_err() - .fields(), - &["sourceDatasetName", "sourceTable", "targetTable"] - ); - } - assert!( - serde_json::from_value::(serde_json::json!({})).is_err() - ); - assert!( - serde_json::from_value::(serde_json::json!({})).is_err() - ); -} - -#[test] -fn bigquery_sync_cron_round_trips_writes_back_and_tolerates_absence() { - let settings = serde_json::json!({ - "replicationMode": "cdc", "replicationMethod": "query_based", - "syncCron": "CRON_TZ=Europe/London 0 * * * *" - }); - let response: ClickPipeBigQueryPipeSettingsResponse = - serde_json::from_value(settings.clone()).unwrap(); - assert_eq!( - response.sync_cron.as_deref(), - Some("CRON_TZ=Europe/London 0 * * * *") - ); - let request = ClickPipeBigQueryPipeSettings::try_from(response).unwrap(); - assert_eq!( - request.sync_cron.as_deref(), - Some("CRON_TZ=Europe/London 0 * * * *") - ); - assert_eq!(request.sync_interval_seconds, None); - assert_eq!(serde_json::to_value(&request).unwrap(), settings); - - let unset = ClickPipeBigQueryPipeSettings { - replication_mode: ClickPipeBigQueryPipeSettingsReplicationmode::Snapshot, - ..Default::default() - }; - assert_eq!( - serde_json::to_value(&unset).unwrap(), - serde_json::json!({"replicationMode": "snapshot"}) - ); - - for value in [ - serde_json::json!({"replicationMode": "cdc"}), - serde_json::json!({"replicationMode": "cdc", "syncCron": null}), - ] { - let response: ClickPipeBigQueryPipeSettingsResponse = - serde_json::from_value(value).unwrap(); - assert_eq!(response.sync_cron, None); - let request = ClickPipeBigQueryPipeSettings::try_from(response).unwrap(); - assert_eq!(request.sync_cron, None); - assert_eq!( - serde_json::to_value(&request).unwrap(), - serde_json::json!({"replicationMode": "cdc"}) - ); - } -} - -#[test] -fn bigquery_replication_modes_cover_cdc_and_preserve_future_values() { - for (wire, expected) in [ - ( - "snapshot", - ClickPipeBigQueryPipeSettingsReplicationmode::Snapshot, - ), - ("cdc", ClickPipeBigQueryPipeSettingsReplicationmode::Cdc), - ( - "cdc_only", - ClickPipeBigQueryPipeSettingsReplicationmode::CdcOnly, - ), - ( - "future", - ClickPipeBigQueryPipeSettingsReplicationmode::Unknown("future".into()), - ), - ] { - let parsed: ClickPipeBigQueryPipeSettingsReplicationmode = - serde_json::from_value(serde_json::json!(wire)).unwrap(); - assert_eq!(parsed, expected); - assert_eq!(parsed.to_string(), wire); - assert_eq!( - serde_json::to_value(parsed).unwrap(), - serde_json::json!(wire) - ); - } - assert_eq!( - ClickPipeBigQueryPipeSettingsReplicationmode::VALUES, - &["snapshot", "cdc", "cdc_only"] - ); -} - -#[test] -fn notification_tolerates_missing_null_and_future_values() { - let missing: Notification = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, Notification::default()); - let null: Notification = serde_json::from_value(serde_json::json!({ - "id": null, "type": null, "severity": null, "resourceType": null, - "resourceId": null, "resourceName": null, "createdAt": null, - "title": null, "description": null, "future": true - })) - .unwrap(); - assert_eq!(null, missing); - assert_eq!(serde_json::to_value(null).unwrap(), serde_json::json!({})); - let future: Notification = serde_json::from_value(serde_json::json!({ - "type": "future.type", "severity": "future.severity", "resourceType": "future.resource" - })) - .unwrap(); - assert_eq!(future.r#type.as_deref(), Some("future.type")); - assert_eq!(future.severity.as_deref(), Some("future.severity")); - assert_eq!(future.resource_type.as_deref(), Some("future.resource")); -} - -/// One spec-shaped payload per `ClickStackFilter` branch, each built from every -/// property of its `*FilterInput` schema plus the `id` the full schema adds. -fn clickstack_filter_kind_payloads() -> [(&'static str, serde_json::Value); 4] { - [ - ( - "QUERY_EXPRESSION", - serde_json::json!({ - "type": "QUERY_EXPRESSION", "id": "f-1", "name": "Environment", - "expression": "environment", "sourceId": "src-1", - "sourceMetricType": "exponential histogram", "where": "ServiceName:api", - "whereLanguage": "lucene", "appliesToSourceIds": ["src-1"], - "isBroadcastEnabled": true, "isVariableEnabled": true, - "variableName": "environment", "minSelections": 1, - "isGlobalRequirement": true, "maxSelections": 1 - }), - ), - ( - "STATIC_LIST", - serde_json::json!({ - "type": "STATIC_LIST", "id": "f-2", "name": "Environment", - "options": ["prod", "staging"], "isBroadcastEnabled": false, - "isVariableEnabled": true, "variableName": "environment", - "minSelections": 0, "isGlobalRequirement": false, "maxSelections": 1 - }), - ), - ( - "PROMETHEUS_LABEL", - serde_json::json!({ - "type": "PROMETHEUS_LABEL", "id": "f-3", "name": "Pod", - "sourceId": "src-2", "label": "pod", "match": "up{job=\"api\"}", - "isBroadcastEnabled": false, "isVariableEnabled": true, - "variableName": "pod", "minSelections": 1, - "isGlobalRequirement": false, "maxSelections": 1 - }), - ), - ( - "SOURCE_FILTER", - serde_json::json!({ - "type": "SOURCE_FILTER", "id": "f-4", "name": "Cluster", - "sourceId": "src-3", "expression": "cluster", - "appliesToSourceIds": ["src-3"], "isBroadcastEnabled": true, - "isVariableEnabled": true, "variableName": "cluster", - "minSelections": 0, "isGlobalRequirement": true, "maxSelections": 1 - }), - ), - ] -} - -#[test] -fn clickstack_filter_unions_route_each_kind_to_its_variant() { - // Dispatch reads `type` alone, so each spec-shaped payload must land in its - // own arm of the update-request, create-request and response unions — and - // never in the first arm that happens to deserialize — then serialize back - // unchanged. - for (kind, payload) in clickstack_filter_kind_payloads() { - let filter: ClickStackFilter = serde_json::from_value(payload.clone()).unwrap(); - let matched = match (kind, &filter) { - ("QUERY_EXPRESSION", ClickStackFilter::ClickStackQueryExpressionFilter(f)) => { - assert_eq!(f.id, "f-1"); - assert_eq!(f.expression, "environment"); - assert_eq!( - f.source_metric_type, - Some( - ClickStackQueryExpressionFilterInputSourcemetrictype::Exponential_histogram - ) - ); - assert_eq!( - f.where_language, - Some(ClickStackQueryExpressionFilterInputWherelanguage::Lucene) - ); - assert_eq!(f.min_selections, Some(ClickStackFilterMinSelections::One)); - assert_eq!(f.max_selections, Some(ClickStackFilterMaxSelections::One)); - true - } - ("STATIC_LIST", ClickStackFilter::ClickStackStaticListFilter(f)) => { - assert_eq!(f.id, "f-2"); - assert_eq!(f.options, ["prod", "staging"]); - assert_eq!(f.min_selections, Some(ClickStackFilterMinSelections::Zero)); - true - } - ("PROMETHEUS_LABEL", ClickStackFilter::ClickStackPrometheusLabelFilter(f)) => { - assert_eq!(f.id, "f-3"); - assert_eq!(f.label, "pod"); - assert_eq!(f.r#match.as_deref(), Some("up{job=\"api\"}")); - true - } - ("SOURCE_FILTER", ClickStackFilter::ClickStackSourceFilter(f)) => { - assert_eq!(f.id, "f-4"); - assert_eq!(f.variable_name, "cluster"); - assert_eq!(f.is_global_requirement, Some(true)); - true - } - _ => false, - }; - assert!(matched, "{kind} routed to {filter:?}"); - assert_eq!(serde_json::to_value(&filter).unwrap(), payload); - - let mut input_payload = payload.clone(); - input_payload.as_object_mut().unwrap().remove("id"); - let input: ClickStackFilterInput = serde_json::from_value(input_payload.clone()).unwrap(); - let matched = matches!( - (kind, &input), - ( - "QUERY_EXPRESSION", - ClickStackFilterInput::ClickStackQueryExpressionFilterInput(_) - ) | ( - "STATIC_LIST", - ClickStackFilterInput::ClickStackStaticListFilterInput(_) - ) | ( - "PROMETHEUS_LABEL", - ClickStackFilterInput::ClickStackPrometheusLabelFilterInput(_) - ) | ( - "SOURCE_FILTER", - ClickStackFilterInput::ClickStackSourceFilterInput(_) - ) - ); - assert!(matched, "{kind} input routed to {input:?}"); - assert_eq!(serde_json::to_value(&input).unwrap(), input_payload); - - let response: ClickStackFilterResponse = serde_json::from_value(payload.clone()).unwrap(); - let matched = match (kind, &response) { - ("QUERY_EXPRESSION", ClickStackFilterResponse::ClickStackQueryExpressionFilter(f)) => { - f.expression.as_deref() == Some("environment") - } - ("STATIC_LIST", ClickStackFilterResponse::ClickStackStaticListFilter(f)) => { - f.options.as_deref() == Some(&["prod".to_string(), "staging".to_string()][..]) - } - ("PROMETHEUS_LABEL", ClickStackFilterResponse::ClickStackPrometheusLabelFilter(f)) => { - f.label.as_deref() == Some("pod") - } - ("SOURCE_FILTER", ClickStackFilterResponse::ClickStackSourceFilter(f)) => { - f.variable_name.as_deref() == Some("cluster") - } - _ => false, - }; - assert!(matched, "{kind} response routed to {response:?}"); - assert_eq!(serde_json::to_value(&response).unwrap(), payload); - } -} - -#[test] -fn clickstack_filter_dispatch_ignores_the_payload_shape() { - // A static-list body relabelled as a source filter is still a source filter - // in the tolerant response union: dispatch never falls through to whichever - // all-`Option` arm accepts the shape. - let mut payload = clickstack_filter_kind_payloads()[1].1.clone(); - payload["type"] = "SOURCE_FILTER".into(); - let response: ClickStackFilterResponse = serde_json::from_value(payload.clone()).unwrap(); - assert!( - matches!( - response, - ClickStackFilterResponse::ClickStackSourceFilter(_) - ), - "{response:?}" - ); - // The strict request union cannot fit it (a source filter needs - // `expression` and `sourceId`), so it keeps the raw JSON rather than - // retrying the static-list arm the shape would fit. - let filter: ClickStackFilter = serde_json::from_value(payload.clone()).unwrap(); - assert_eq!(filter, ClickStackFilter::Unknown(payload)); -} - -#[test] -fn clickstack_filter_unknown_or_missing_type_round_trips() { - for json in [ - r#"{"type":"FUTURE_KIND","id":"f-9","name":"x"}"#, - r#"{"id":"f-9","name":"x","expression":"e","sourceId":"s"}"#, - ] { - assert_unknown_variant_round_trips(json, |f: &ClickStackFilter| { - matches!(f, ClickStackFilter::Unknown(_)) - }); - assert_unknown_variant_round_trips(json, |f: &ClickStackFilterInput| { - matches!(f, ClickStackFilterInput::Unknown(_)) - }); - assert_unknown_variant_round_trips(json, |f: &ClickStackFilterResponse| { - matches!(f, ClickStackFilterResponse::Unknown(_)) - }); - } -} - -#[test] -fn clickstack_filter_response_treats_dropped_and_null_fields_as_absent() { - for (kind, _) in clickstack_filter_kind_payloads() { - for json in [ - serde_json::json!({"type": kind}), - serde_json::json!({ - "type": kind, "id": null, "name": null, "expression": null, - "sourceId": null, "options": null, "label": null, "match": null, - "sourceMetricType": null, "where": null, "whereLanguage": null, - "appliesToSourceIds": null, "isBroadcastEnabled": null, - "isVariableEnabled": null, "variableName": null, "minSelections": null, - "isGlobalRequirement": null, "maxSelections": null - }), - ] { - let response: ClickStackFilterResponse = serde_json::from_value(json).unwrap(); - let sparse = match &response { - ClickStackFilterResponse::ClickStackQueryExpressionFilter(f) => { - f.id.is_none() - && f.expression.is_none() - && f.source_metric_type.is_none() - && f.min_selections.is_none() - && f.max_selections.is_none() - && f.is_global_requirement.is_none() - } - ClickStackFilterResponse::ClickStackStaticListFilter(f) => { - f.id.is_none() && f.options.is_none() && f.variable_name.is_none() - } - ClickStackFilterResponse::ClickStackPrometheusLabelFilter(f) => { - f.label.is_none() && f.r#match.is_none() && f.source_id.is_none() - } - ClickStackFilterResponse::ClickStackSourceFilter(f) => { - f.expression.is_none() && f.variable_name.is_none() - } - ClickStackFilterResponse::Unknown(_) => false, - }; - assert!(sparse, "{kind}: {response:?}"); - // Absent fields are omitted, never re-emitted as `null`. - assert_eq!( - serde_json::to_value(&response).unwrap(), - serde_json::json!({"type": kind}) - ); - } - } -} - -#[test] -fn clickstack_filter_try_from_response_converts_every_kind() { - for (kind, payload) in clickstack_filter_kind_payloads() { - let response: ClickStackFilterResponse = serde_json::from_value(payload.clone()).unwrap(); - let request = ClickStackFilter::try_from(response).unwrap(); - assert!( - !matches!(request, ClickStackFilter::Unknown(_)), - "{kind}: {request:?}" - ); - assert_eq!(serde_json::to_value(&request).unwrap(), payload); - } -} - -#[test] -fn clickstack_filter_try_from_response_names_every_missing_required_field() { - for (json, expected) in [ - ( - r#"{"type":"QUERY_EXPRESSION"}"#, - &["expression", "id", "name", "sourceId"][..], - ), - ( - r#"{"type":"STATIC_LIST","id":"f"}"#, - &["name", "options"][..], - ), - ( - r#"{"type":"PROMETHEUS_LABEL","name":"n"}"#, - &["id", "label", "sourceId"][..], - ), - ( - r#"{"type":"SOURCE_FILTER","id":"f","name":"n","sourceId":"s"}"#, - &["expression", "variableName"][..], - ), - ] { - let response: ClickStackFilterResponse = serde_json::from_str(json).unwrap(); - let error = ClickStackFilter::try_from(response).unwrap_err(); - assert_eq!(error.fields(), expected, "{json}"); - } - - // An unmodelled kind passes through losslessly. - let json = r#"{"type":"FUTURE_KIND","id":"f"}"#; - let response: ClickStackFilterResponse = serde_json::from_str(json).unwrap(); - let request = ClickStackFilter::try_from(response).unwrap(); - assert_eq!( - serde_json::to_value(&request).unwrap(), - serde_json::from_str::(json).unwrap() - ); -} - -#[test] -fn clickstack_filter_request_variants_stay_strict() { - // Each create-request filter variant requires its schema's required fields. - macro_rules! assert_rejects_empty { - ($($model:ty),+ $(,)?) => { - $( - assert!( - serde_json::from_str::<$model>("{}").is_err(), - "{} accepted a payload missing its required fields", - stringify!($model), - ); - )+ - }; - } - assert_rejects_empty!( - ClickStackQueryExpressionFilterInput, - ClickStackStaticListFilterInput, - ClickStackPrometheusLabelFilterInput, - ClickStackSourceFilterInput, - ); - // The update-request variant adds a required `id` on top of the input. - let mut payload = clickstack_filter_kind_payloads()[0].1.clone(); - payload.as_object_mut().unwrap().remove("id"); - assert!(serde_json::from_value::(payload.clone()).is_err()); - serde_json::from_value::(payload).unwrap(); -} - -#[test] -fn clickstack_filter_selection_counts_are_integer_enums() { - let filter: ClickStackStaticListFilterInput = serde_json::from_value(serde_json::json!({ - "type": "STATIC_LIST", "name": "n", "options": ["a"], - "minSelections": 1, "maxSelections": 1 - })) - .unwrap(); - assert_eq!( - filter.min_selections, - Some(ClickStackFilterMinSelections::One) - ); - assert_eq!( - filter.max_selections, - Some(ClickStackFilterMaxSelections::One) - ); - let v = serde_json::to_value(&filter).unwrap(); - assert_eq!(v["minSelections"], 1); - assert_eq!(v["maxSelections"], 1); - - // A value the spec does not list is kept, not rejected. - let min: ClickStackFilterMinSelections = serde_json::from_str("2").unwrap(); - assert_eq!(min, ClickStackFilterMinSelections::Unknown(2)); - assert_eq!(serde_json::to_string(&min).unwrap(), "2"); - assert_eq!(min.to_string(), "2"); - let zero: ClickStackFilterMinSelections = serde_json::from_str("0").unwrap(); - assert_eq!(zero, ClickStackFilterMinSelections::Zero); - let max: ClickStackFilterMaxSelections = serde_json::from_str("3").unwrap(); - assert_eq!(max, ClickStackFilterMaxSelections::Unknown(3)); -} - -#[test] -fn clickstack_dashboard_response_carries_every_filter_kind() { - let filters: Vec = clickstack_filter_kind_payloads() - .into_iter() - .map(|(_, payload)| payload) - .collect(); - let dashboard: ClickStackDashboardResponse = serde_json::from_value(serde_json::json!({ - "id": "d-1", "name": "Overview", "tiles": [], "filters": filters - })) - .unwrap(); - assert!(matches!( - dashboard.filters.as_deref(), - Some([ - ClickStackFilterResponse::ClickStackQueryExpressionFilter(_), - ClickStackFilterResponse::ClickStackStaticListFilter(_), - ClickStackFilterResponse::ClickStackPrometheusLabelFilter(_), - ClickStackFilterResponse::ClickStackSourceFilter(_), - ]) - )); -} - -#[test] -fn clickstack_heatmap_chart_config_routes_each_mode_to_its_variant() { - let distribution = serde_json::json!({ - "displayType": "heatmap", "heatmapMode": "distribution", "sourceId": "src-1", - "select": [{"valueExpression": "Duration", "countExpression": "count()", "heatmapScaleType": "log"}], - "where": "ServiceName = 'api'", "whereLanguage": "sql" - }); - let series = serde_json::json!({ - "displayType": "heatmap", "heatmapMode": "series", "sourceId": "src-1", - "select": [{"aggFn": "count"}], "groupBy": "ServiceName" - }); - let mut implicit_distribution = distribution.clone(); - implicit_distribution - .as_object_mut() - .unwrap() - .remove("heatmapMode"); - - for payload in [&distribution, &implicit_distribution] { - let config: ClickStackHeatmapChartConfig = serde_json::from_value(payload.clone()).unwrap(); - let ClickStackHeatmapChartConfig::ClickStackHeatmapDistributionChartConfig(d) = &config - else { - panic!("expected the distribution variant, got {config:?}"); - }; - assert_eq!(d.select[0].value_expression, "Duration"); - assert_eq!( - d.where_language, - Some(ClickStackHeatmapDistributionChartConfigWherelanguage::Sql) - ); - assert_eq!(serde_json::to_value(&config).unwrap(), *payload); - - let response: ClickStackHeatmapChartConfigResponse = - serde_json::from_value(payload.clone()).unwrap(); - assert!( - matches!( - response, - ClickStackHeatmapChartConfigResponse::ClickStackHeatmapDistributionChartConfig(_) - ), - "{response:?}" - ); - } - - let config: ClickStackHeatmapChartConfig = serde_json::from_value(series.clone()).unwrap(); - let ClickStackHeatmapChartConfig::ClickStackHeatmapSeriesChartConfig(s) = &config else { - panic!("expected the series variant, got {config:?}"); - }; - assert_eq!( - s.heatmap_mode, - ClickStackHeatmapSeriesChartConfigHeatmapmode::Series - ); - assert_eq!(s.group_by.as_deref(), Some("ServiceName")); - assert_eq!(s.select[0].agg_fn, ClickStackSelectItemAggfn::Count); - assert_eq!(serde_json::to_value(&config).unwrap(), series); - - let response: ClickStackHeatmapChartConfigResponse = - serde_json::from_value(series.clone()).unwrap(); - let ClickStackHeatmapChartConfigResponse::ClickStackHeatmapSeriesChartConfig(s) = &response - else { - panic!("expected the series variant, got {response:?}"); - }; - assert_eq!(s.group_by.as_deref(), Some("ServiceName")); - - // Reached through the tile config's `displayType: "heatmap"` arm. - let tile: ClickStackTileConfigResponse = serde_json::from_value(series.clone()).unwrap(); - assert!(matches!( - tile, - ClickStackTileConfigResponse::ClickStackHeatmapChartConfig( - ClickStackHeatmapChartConfigResponse::ClickStackHeatmapSeriesChartConfig(_) - ) - )); -} - -#[test] -fn clickstack_heatmap_chart_config_guards_series_payloads_without_a_mode() { - // A series payload whose `heatmapMode` is dropped still carries the - // series-only `groupBy`, so it must not be read as a distribution heatmap. - let json = r#"{"displayType":"heatmap","sourceId":"s","select":[{"aggFn":"count"}],"groupBy":"ServiceName"}"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackHeatmapChartConfig| { - matches!(c, ClickStackHeatmapChartConfig::Unknown(_)) - }); - assert_unknown_variant_round_trips(json, |c: &ClickStackHeatmapChartConfigResponse| { - matches!(c, ClickStackHeatmapChartConfigResponse::Unknown(_)) - }); - let json = r#"{"displayType":"heatmap","heatmapMode":"timeline","sourceId":"s"}"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackHeatmapChartConfigResponse| { - matches!(c, ClickStackHeatmapChartConfigResponse::Unknown(_)) - }); -} - -#[test] -fn clickstack_heatmap_chart_config_response_treats_dropped_and_null_fields_as_absent() { - let response: ClickStackHeatmapChartConfigResponse = - serde_json::from_str(r#"{"heatmapMode":"series","groupBy":null,"select":null}"#).unwrap(); - assert_eq!( - response, - ClickStackHeatmapChartConfigResponse::ClickStackHeatmapSeriesChartConfig( - ClickStackHeatmapSeriesChartConfigResponse { - heatmap_mode: Some(ClickStackHeatmapSeriesChartConfigHeatmapmode::Series), - ..Default::default() - } - ) - ); - let response: ClickStackHeatmapChartConfigResponse = - serde_json::from_str(r#"{"displayType":"heatmap","where":null}"#).unwrap(); - assert_eq!( - response, - ClickStackHeatmapChartConfigResponse::ClickStackHeatmapDistributionChartConfig( - ClickStackHeatmapDistributionChartConfigResponse { - display_type: Some(ClickStackHeatmapDistributionChartConfigDisplaytype::Heatmap), - ..Default::default() - } - ) - ); -} - -#[test] -fn clickstack_tile_config_stacked_line_routes_builder_and_raw_sql() { - let builder = serde_json::json!({ - "displayType": "stacked_line", "sourceId": "src-1", "select": [{"aggFn": "count"}], - "groupBy": "service", "asRatio": false, "alignDateRangeToGranularity": true, - "fillNulls": true, "lineInterpolation": "step", "seriesLimit": 5, - "formulas": [{"expression": "A * 100"}], "showOperandSeries": false - }); - let raw = serde_json::json!({ - "displayType": "stacked_line", "configType": "sql", "connectionId": "conn-1", - "sqlTemplate": "SELECT 1", "sourceId": "src-1", "fillNulls": false, - "alignDateRangeToGranularity": false, "lineInterpolation": "smooth" - }); - - let tile: ClickStackTileConfig = serde_json::from_value(builder.clone()).unwrap(); - let ClickStackTileConfig::ClickStackStackedLineChartConfig( - ClickStackStackedLineChartConfig::ClickStackStackedLineBuilderChartConfig(b), - ) = &tile - else { - panic!("expected the stacked-line builder variant, got {tile:?}"); - }; - assert_eq!( - b.display_type, - ClickStackStackedLineBuilderChartConfigDisplaytype::Stacked_line - ); - assert_eq!( - b.line_interpolation, - Some(ClickStackStackedLineBuilderChartConfigLineinterpolation::Step) - ); - assert_eq!(b.series_limit, Some(5)); - assert_eq!(serde_json::to_value(&tile).unwrap(), builder); - - let tile: ClickStackTileConfig = serde_json::from_value(raw.clone()).unwrap(); - let ClickStackTileConfig::ClickStackStackedLineChartConfig( - ClickStackStackedLineChartConfig::ClickStackStackedLineRawSqlChartConfig(r), - ) = &tile - else { - panic!("expected the stacked-line raw SQL variant, got {tile:?}"); - }; - assert_eq!(r.connection_id, "conn-1"); - assert_eq!( - r.line_interpolation, - Some(ClickStackStackedLineRawSqlChartConfigLineinterpolation::Smooth) - ); - assert_eq!(serde_json::to_value(&tile).unwrap(), raw); - - let tile: ClickStackTileConfigResponse = serde_json::from_value(builder).unwrap(); - assert!(matches!( - tile, - ClickStackTileConfigResponse::ClickStackStackedLineChartConfig( - ClickStackStackedLineChartConfigResponse::ClickStackStackedLineBuilderChartConfig(_) - ) - )); - let tile: ClickStackTileConfigResponse = serde_json::from_value(raw.clone()).unwrap(); - assert!(matches!( - tile, - ClickStackTileConfigResponse::ClickStackStackedLineChartConfig( - ClickStackStackedLineChartConfigResponse::ClickStackStackedLineRawSqlChartConfig(_) - ) - )); - - // A raw-SQL body that lost its `configType` is kept out of the builder arm. - let mut unmarked = raw; - unmarked.as_object_mut().unwrap().remove("configType"); - assert_unknown_variant_round_trips( - &unmarked.to_string(), - |c: &ClickStackStackedLineChartConfigResponse| { - matches!(c, ClickStackStackedLineChartConfigResponse::Unknown(_)) - }, - ); -} - -#[test] -fn clickstack_stacked_line_response_treats_dropped_and_null_fields_as_absent() { - let response: ClickStackStackedLineChartConfigResponse = serde_json::from_str( - r#"{"displayType":"stacked_line","lineInterpolation":null,"select":null,"seriesLimit":null}"#, - ) - .unwrap(); - assert_eq!( - response, - ClickStackStackedLineChartConfigResponse::ClickStackStackedLineBuilderChartConfig( - ClickStackStackedLineBuilderChartConfigResponse { - display_type: Some( - ClickStackStackedLineBuilderChartConfigDisplaytype::Stacked_line - ), - ..Default::default() - } - ) - ); - let response: ClickStackStackedLineChartConfigResponse = - serde_json::from_str(r#"{"configType":"sql","connectionId":null}"#).unwrap(); - assert_eq!( - response, - ClickStackStackedLineChartConfigResponse::ClickStackStackedLineRawSqlChartConfig( - ClickStackStackedLineRawSqlChartConfigResponse { - config_type: Some(ClickStackStackedLineRawSqlChartConfigConfigtype::Sql), - ..Default::default() - } - ) - ); -} - -#[test] -fn clickstack_line_charts_carry_line_interpolation() { - let builder: ClickStackLineBuilderChartConfig = serde_json::from_value(serde_json::json!({ - "displayType": "line", "sourceId": "s", "select": [], "lineInterpolation": "linear" - })) - .unwrap(); - assert_eq!( - builder.line_interpolation, - Some(ClickStackLineBuilderChartConfigLineinterpolation::Linear) - ); - assert_eq!( - serde_json::to_value(&builder).unwrap()["lineInterpolation"], - "linear" - ); - let raw: ClickStackLineRawSqlChartConfig = serde_json::from_value(serde_json::json!({ - "displayType": "line", "configType": "sql", "connectionId": "c", - "sqlTemplate": "SELECT 1", "lineInterpolation": "step" - })) - .unwrap(); - assert_eq!( - raw.line_interpolation, - Some(ClickStackLineRawSqlChartConfigLineinterpolation::Step) - ); - let unknown: ClickStackLineBuilderChartConfigLineinterpolation = - serde_json::from_str(r#""cubic""#).unwrap(); - assert_eq!( - unknown, - ClickStackLineBuilderChartConfigLineinterpolation::Unknown("cubic".to_string()) - ); - - // Absent or null in a response is `None`, and absent is omitted on output. - for json in [r#"{}"#, r#"{"lineInterpolation":null}"#] { - let response: ClickStackLineBuilderChartConfigResponse = - serde_json::from_str(json).unwrap(); - assert_eq!(response.line_interpolation, None); - let response: ClickStackLineRawSqlChartConfigResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.line_interpolation, None); - assert!( - serde_json::to_value(&response) - .unwrap() - .get("lineInterpolation") - .is_none() - ); - } -} - -#[test] -fn clickstack_number_raw_sql_chart_carries_color_rules() { - let config: ClickStackNumberRawSqlChartConfig = serde_json::from_value(serde_json::json!({ - "displayType": "number", "configType": "sql", "connectionId": "c", - "sqlTemplate": "SELECT 1", - "colorRules": [{"operator": "gt", "value": 100.0, "color": "chart-error"}] - })) - .unwrap(); - let rules = config.color_rules.as_deref().unwrap(); - assert!(matches!( - rules, - [ClickStackNumberTileColorCondition::ClickStackNumericColorCondition(_)] - )); - assert_eq!( - serde_json::to_value(&config).unwrap()["colorRules"][0]["operator"], - "gt" - ); - for json in [r#"{}"#, r#"{"colorRules":null}"#] { - let response: ClickStackNumberRawSqlChartConfigResponse = - serde_json::from_str(json).unwrap(); - assert_eq!(response.color_rules, None); - } - let response: ClickStackNumberRawSqlChartConfigResponse = serde_json::from_str( - r#"{"colorRules":[{"operator":"eq","value":"ok","color":"chart-success"}]}"#, - ) - .unwrap(); - assert!(matches!( - response.color_rules.as_deref(), - Some([ClickStackNumberTileColorConditionResponse::ClickStackEqualityColorCondition(_)]) - )); -} - -#[test] -fn clickstack_tiles_carry_a_description() { - let tile: ClickStackTileInput = serde_json::from_value(serde_json::json!({ - "name": "Error Rate", "x": 0, "y": 0, "w": 6, "h": 3, - "description": "Share of API requests that returned a 5xx response." - })) - .unwrap(); - assert_eq!( - tile.description.as_deref(), - Some("Share of API requests that returned a 5xx response.") - ); - let v = serde_json::to_value(ClickStackTileInput::default()).unwrap(); - assert!(v.get("description").is_none()); - - let output: ClickStackTileOutput = - serde_json::from_str(r#"{"id":"t-1","description":"tooltip"}"#).unwrap(); - assert_eq!(output.description.as_deref(), Some("tooltip")); - for json in [r#"{}"#, r#"{"description":null}"#] { - let output: ClickStackTileOutput = serde_json::from_str(json).unwrap(); - assert_eq!(output.description, None); - } -} - -#[test] -fn clickstack_variable_saved_filter_value_carries_all() { - let saved: ClickStackSavedFilterValue = serde_json::from_value(serde_json::json!({ - "type": "variable", "name": "cluster", "values": [], "all": true - })) - .unwrap(); - let ClickStackSavedFilterValue::ClickStackVariableSavedFilterValue(value) = &saved else { - panic!("expected the variable variant, got {saved:?}"); - }; - assert_eq!(value.all, Some(true)); - - for json in [ - r#"{"type":"variable","name":"n","values":[]}"#, - r#"{"type":"variable","name":"n","values":[],"all":null}"#, - ] { - let response: ClickStackVariableSavedFilterValueResponse = - serde_json::from_str(json).unwrap(); - assert_eq!(response.all, None); - } - - // Write-back keeps `all`. - let response: ClickStackVariableSavedFilterValueResponse = - serde_json::from_str(r#"{"type":"variable","name":"n","values":[],"all":true}"#).unwrap(); - let request = ClickStackVariableSavedFilterValue::try_from(response).unwrap(); - assert_eq!(request.all, Some(true)); -} - -#[test] -fn clickstack_time_chart_series_accepts_stacked_line() { - let display: ClickStackTimeChartSeriesDisplaytype = - serde_json::from_str(r#""stacked_line""#).unwrap(); - assert_eq!(display, ClickStackTimeChartSeriesDisplaytype::Stacked_line); - assert_eq!(display.to_string(), "stacked_line"); - assert_eq!( - serde_json::to_string(&display).unwrap(), - r#""stacked_line""# - ); -} - -/// Spec-shaped payloads for every arm of `ClickStackAlertChartConfig`: -/// `(kind, builder, raw SQL)`. Each carries fields only its own variant -/// defines, plus the alert overlay's `name` (and, on builders, `where`). -fn clickstack_alert_chart_config_payloads() --> [(&'static str, serde_json::Value, serde_json::Value); 4] { - [ - ( - "Line", - serde_json::json!({ - "displayType": "line", "sourceId": "src-1", "select": [{"aggFn": "count"}], - "groupBy": "ServiceName", "lineInterpolation": "step", "fitYAxisToData": true, - "name": "Error Rate Query", "where": "ServiceName:api", "whereLanguage": "lucene" - }), - serde_json::json!({ - "displayType": "line", "configType": "sql", "connectionId": "conn-1", - "sqlTemplate": "SELECT 1", "compareToPreviousPeriod": true, - "name": "Error Rate Query" - }), - ), - ( - "Bar", - serde_json::json!({ - "displayType": "stacked_bar", "sourceId": "src-1", "select": [{"aggFn": "count"}], - "seriesLimit": 5, "name": "Errors by service", "where": "level = 'error'", - "whereLanguage": "sql" - }), - serde_json::json!({ - "displayType": "stacked_bar", "configType": "sql", "connectionId": "conn-1", - "sqlTemplate": "SELECT 1", "fillNulls": true, "name": "Errors by service" - }), - ), - ( - "StackedLine", - serde_json::json!({ - "displayType": "stacked_line", "sourceId": "src-1", "select": [{"aggFn": "count"}], - "lineInterpolation": "linear", "showOperandSeries": false, - "name": "Latency bands", "where": "ServiceName:api", "whereLanguage": "lucene" - }), - serde_json::json!({ - "displayType": "stacked_line", "configType": "sql", "connectionId": "conn-1", - "sqlTemplate": "SELECT 1", "lineInterpolation": "smooth", "name": "Latency bands" - }), - ), - ( - "Number", - serde_json::json!({ - "displayType": "number", "sourceId": "src-1", "select": [{"aggFn": "count"}], - "color": "chart-red", "name": "Error count", "where": "level:error", - "whereLanguage": "lucene" - }), - serde_json::json!({ - "displayType": "number", "configType": "sql", "connectionId": "conn-1", - "sqlTemplate": "SELECT count() FROM otel_logs", "color": "chart-green", - "name": "Error count" - }), - ), - ] -} - -/// The `{outer}/{inner}` arm names a request alert chart config landed in. -fn clickstack_alert_chart_config_arm(config: &ClickStackAlertChartConfig) -> String { - let inner = match config { - ClickStackAlertChartConfig::ClickStackAlertLineChartConfig(c) => c.to_string(), - ClickStackAlertChartConfig::ClickStackAlertBarChartConfig(c) => c.to_string(), - ClickStackAlertChartConfig::ClickStackAlertStackedLineChartConfig(c) => c.to_string(), - ClickStackAlertChartConfig::ClickStackAlertNumberChartConfig(c) => c.to_string(), - ClickStackAlertChartConfig::Unknown(v) => format!("Unknown({v})"), - }; - format!("{config}/{inner}") -} - -/// The `{outer}/{inner}` arm names a response alert chart config landed in. -fn clickstack_alert_chart_config_response_arm( - config: &ClickStackAlertChartConfigResponse, -) -> String { - let inner = match config { - ClickStackAlertChartConfigResponse::ClickStackAlertLineChartConfig(c) => c.to_string(), - ClickStackAlertChartConfigResponse::ClickStackAlertBarChartConfig(c) => c.to_string(), - ClickStackAlertChartConfigResponse::ClickStackAlertStackedLineChartConfig(c) => { - c.to_string() - } - ClickStackAlertChartConfigResponse::ClickStackAlertNumberChartConfig(c) => c.to_string(), - ClickStackAlertChartConfigResponse::Unknown(v) => format!("Unknown({v})"), - }; - format!("{config}/{inner}") -} - -#[test] -fn clickstack_alert_chart_config_routes_each_display_type_and_config_type_to_its_variant() { - for (kind, builder, raw) in clickstack_alert_chart_config_payloads() { - for (payload, variant) in [(builder, "Builder"), (raw, "RawSql")] { - let expected = format!( - "ClickStackAlert{kind}ChartConfig/ClickStackAlert{kind}{variant}ChartConfig" - ); - - let config: ClickStackAlertChartConfig = - serde_json::from_value(payload.clone()).unwrap(); - assert_eq!(clickstack_alert_chart_config_arm(&config), expected); - assert_eq!(serde_json::to_value(&config).unwrap(), payload); - - let response: ClickStackAlertChartConfigResponse = - serde_json::from_value(payload.clone()).unwrap(); - assert_eq!( - clickstack_alert_chart_config_response_arm(&response), - expected - ); - assert_eq!(serde_json::to_value(&response).unwrap(), payload); - } - } - - // The overlay's fields land on the variant structs themselves. - let [(_, line_builder, _), _, _, (_, _, number_raw)] = clickstack_alert_chart_config_payloads(); - let config: ClickStackAlertChartConfig = serde_json::from_value(line_builder).unwrap(); - let ClickStackAlertChartConfig::ClickStackAlertLineChartConfig( - ClickStackAlertLineChartConfig::ClickStackAlertLineBuilderChartConfig(b), - ) = &config - else { - panic!("expected the line builder variant, got {config:?}"); - }; - assert_eq!(b.name.as_deref(), Some("Error Rate Query")); - assert_eq!(b.r#where.as_deref(), Some("ServiceName:api")); - assert_eq!( - b.where_language, - Some(ClickStackAlertChartConfigOverlayWherelanguage::Lucene) - ); - assert_eq!( - b.line_interpolation, - Some(ClickStackLineBuilderChartConfigLineinterpolation::Step) - ); - assert_eq!(b.select[0].agg_fn, ClickStackSelectItemAggfn::Count); - - let response: ClickStackAlertChartConfigResponse = serde_json::from_value(number_raw).unwrap(); - let ClickStackAlertChartConfigResponse::ClickStackAlertNumberChartConfig( - ClickStackAlertNumberChartConfigResponse::ClickStackAlertNumberRawSqlChartConfig(r), - ) = &response - else { - panic!("expected the number raw SQL variant, got {response:?}"); - }; - assert_eq!(r.connection_id.as_deref(), Some("conn-1")); - assert_eq!(r.name.as_deref(), Some("Error count")); - assert_eq!( - r.config_type, - Some(ClickStackNumberRawSqlChartConfigConfigtype::Sql) - ); -} - -#[test] -fn clickstack_alert_chart_config_keeps_payloads_out_of_variants_they_do_not_fit() { - for (kind, builder, raw) in clickstack_alert_chart_config_payloads() { - // A raw-SQL body that lost its `configType` stays out of the builder - // arm, request and response alike, and round-trips verbatim. - let mut unmarked = raw.clone(); - unmarked.as_object_mut().unwrap().remove("configType"); - // An inner `Unknown` displays as its raw JSON. - let expected = format!("ClickStackAlert{kind}ChartConfig/{unmarked}"); - let config: ClickStackAlertChartConfig = serde_json::from_value(unmarked.clone()).unwrap(); - assert_eq!(clickstack_alert_chart_config_arm(&config), expected); - assert_eq!(serde_json::to_value(&config).unwrap(), unmarked); - let response: ClickStackAlertChartConfigResponse = - serde_json::from_value(unmarked.clone()).unwrap(); - assert_eq!( - clickstack_alert_chart_config_response_arm(&response), - expected - ); - assert_eq!(serde_json::to_value(&response).unwrap(), unmarked); - - // The request builder keeps the chart config's required fields. - let mut sourceless = builder.clone(); - sourceless.as_object_mut().unwrap().remove("sourceId"); - let config: ClickStackAlertChartConfig = - serde_json::from_value(sourceless.clone()).unwrap(); - assert_eq!( - clickstack_alert_chart_config_arm(&config), - format!("ClickStackAlert{kind}ChartConfig/{sourceless}") - ); - // The request raw-SQL variant does too. - let mut templateless = raw.clone(); - templateless.as_object_mut().unwrap().remove("sqlTemplate"); - let config: ClickStackAlertChartConfig = - serde_json::from_value(templateless.clone()).unwrap(); - assert_eq!( - clickstack_alert_chart_config_arm(&config), - format!("ClickStackAlert{kind}ChartConfig/{templateless}") - ); - // A response tolerates both, per field. - let response: ClickStackAlertChartConfigResponse = - serde_json::from_value(sourceless).unwrap(); - assert_eq!( - clickstack_alert_chart_config_response_arm(&response), - format!("ClickStackAlert{kind}ChartConfig/ClickStackAlert{kind}BuilderChartConfig") - ); - let response: ClickStackAlertChartConfigResponse = - serde_json::from_value(templateless).unwrap(); - assert_eq!( - clickstack_alert_chart_config_response_arm(&response), - format!("ClickStackAlert{kind}ChartConfig/ClickStackAlert{kind}RawSqlChartConfig") - ); - - // Display types an alert cannot use, or none at all, are Unknown. - for display_type in [Some("pie"), Some("table"), Some("bar"), None] { - let mut other = builder.clone(); - match display_type { - Some(value) => other["displayType"] = serde_json::Value::from(value), - None => { - other.as_object_mut().unwrap().remove("displayType"); - } - } - assert_unknown_variant_round_trips( - &other.to_string(), - |c: &ClickStackAlertChartConfig| { - matches!(c, ClickStackAlertChartConfig::Unknown(_)) - }, - ); - assert_unknown_variant_round_trips( - &other.to_string(), - |c: &ClickStackAlertChartConfigResponse| { - matches!(c, ClickStackAlertChartConfigResponse::Unknown(_)) - }, - ); - } - } - - // A recognized discriminator whose payload changed shape is kept verbatim. - let json = r#"{"displayType":"number","configType":"sql","sqlTemplate":5}"#; - assert_unknown_variant_round_trips(json, |c: &ClickStackAlertChartConfigResponse| { - matches!( - c, - ClickStackAlertChartConfigResponse::ClickStackAlertNumberChartConfig( - ClickStackAlertNumberChartConfigResponse::Unknown(_) - ) - ) - }); -} - -#[test] -fn clickstack_alert_chart_config_responses_treat_dropped_and_null_fields_as_absent() { - macro_rules! assert_absent_and_null_are_none { - ($($response:ty),+ $(,)?) => { - $( - for wire in [ - "{}", - r#"{"name":null,"where":null,"whereLanguage":null,"sourceId":null,"numberFormat":null}"#, - ] { - let parsed: $response = serde_json::from_str(wire).unwrap(); - assert_eq!(parsed, <$response>::default(), "{} from {wire}", stringify!($response)); - assert_eq!(serde_json::to_value(&parsed).unwrap(), serde_json::json!({})); - } - )+ - }; - } - assert_absent_and_null_are_none!( - ClickStackAlertBarBuilderChartConfigResponse, - ClickStackAlertBarRawSqlChartConfigResponse, - ClickStackAlertLineBuilderChartConfigResponse, - ClickStackAlertLineRawSqlChartConfigResponse, - ClickStackAlertNumberBuilderChartConfigResponse, - ClickStackAlertNumberRawSqlChartConfigResponse, - ClickStackAlertStackedLineBuilderChartConfigResponse, - ClickStackAlertStackedLineRawSqlChartConfigResponse, - ); - - let response: ClickStackAlertChartConfigResponse = - serde_json::from_str(r#"{"displayType":"stacked_line","name":null,"select":null}"#) - .unwrap(); - assert_eq!( - response, - ClickStackAlertChartConfigResponse::ClickStackAlertStackedLineChartConfig( - ClickStackAlertStackedLineChartConfigResponse::ClickStackAlertStackedLineBuilderChartConfig( - ClickStackAlertStackedLineBuilderChartConfigResponse { - display_type: Some( - ClickStackStackedLineBuilderChartConfigDisplaytype::Stacked_line - ), - ..Default::default() - } - ) - ) - ); -} - -#[test] -fn clickstack_alert_chart_config_overlay_fields_are_all_optional() { - let overlay: ClickStackAlertChartConfigOverlay = serde_json::from_str("{}").unwrap(); - assert_eq!(overlay, ClickStackAlertChartConfigOverlay::default()); - let wire = serde_json::json!({"name": "Q", "where": "a:b", "whereLanguage": "sql"}); - let overlay: ClickStackAlertChartConfigOverlay = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!( - overlay.where_language, - Some(ClickStackAlertChartConfigOverlayWherelanguage::Sql) - ); - assert_eq!(serde_json::to_value(&overlay).unwrap(), wire); - let language: ClickStackAlertChartConfigOverlayWherelanguage = - serde_json::from_str(r#""kql""#).unwrap(); - assert_eq!( - language, - ClickStackAlertChartConfigOverlayWherelanguage::Unknown("kql".into()) - ); -} - -#[test] -fn clickstack_alert_sources_accept_inline() { - assert_eq!( - serde_json::from_str::(r#""inline""#).unwrap(), - ClickStackAlertResponseSource::Inline - ); - assert_eq!( - serde_json::from_str::(r#""inline""#).unwrap(), - ClickStackCreateAlertRequestSource::Inline - ); - assert_eq!( - serde_json::from_str::(r#""inline""#).unwrap(), - ClickStackUpdateAlertRequestSource::Inline - ); - assert_eq!(ClickStackAlertResponseSource::Inline.to_string(), "inline"); - assert_eq!( - ClickStackCreateAlertRequestSource::Inline.to_string(), - "inline" - ); - assert_eq!( - ClickStackUpdateAlertRequestSource::Inline.to_string(), - "inline" - ); -} - -#[test] -fn clickstack_alert_requests_send_inline_chart_configs_display_names_and_tags() { - let [(_, builder, _), ..] = clickstack_alert_chart_config_payloads(); - let wire = serde_json::json!({ - "source": "inline", "chartConfig": builder, "threshold": 10.0, "interval": "5m", - "thresholdType": "above", "displayName": "Checkout error spike", - "tags": ["checkout", "p1"] - }); - - let create: ClickStackCreateAlertRequest = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!( - create.source, - Some(ClickStackCreateAlertRequestSource::Inline) - ); - assert!(matches!( - create.chart_config, - Some(ClickStackAlertChartConfig::ClickStackAlertLineChartConfig( - ClickStackAlertLineChartConfig::ClickStackAlertLineBuilderChartConfig(_) - )) - )); - assert_eq!(create.display_name.as_deref(), Some("Checkout error spike")); - assert_eq!( - create.tags.as_deref(), - Some(&["checkout".into(), "p1".into()][..]) - ); - assert_eq!( - (create.channel.as_ref(), create.channels.as_ref()), - (None, None) - ); - assert_eq!(serde_json::to_value(&create).unwrap(), wire); - - let update: ClickStackUpdateAlertRequest = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!( - update.source, - Some(ClickStackUpdateAlertRequestSource::Inline) - ); - assert_eq!(update.display_name.as_deref(), Some("Checkout error spike")); - assert_eq!(serde_json::to_value(&update).unwrap(), wire); -} - -#[test] -fn clickstack_alert_requests_omit_unset_source_and_channels_but_stay_strict() { - // `source`, `channel` and `channels` are no longer required, so an unset - // value is omitted from the wire rather than defaulted. - let create = serde_json::to_value(ClickStackCreateAlertRequest::default()).unwrap(); - let update = serde_json::to_value(ClickStackUpdateAlertRequest::default()).unwrap(); - for wire in [&create, &update] { - for key in [ - "source", - "channel", - "channels", - "chartConfig", - "displayName", - "tags", - ] { - assert!(wire.get(key).is_none(), "{key} was sent unset: {wire}"); - } - } - - // `threshold`, `interval` and `thresholdType` stay required. - let minimal = serde_json::json!({"threshold": 1.0, "interval": "1m", "thresholdType": "above"}); - let parsed: ClickStackCreateAlertRequest = serde_json::from_value(minimal.clone()).unwrap(); - assert_eq!(serde_json::to_value(&parsed).unwrap(), minimal); - let parsed: ClickStackUpdateAlertRequest = serde_json::from_value(minimal.clone()).unwrap(); - assert_eq!(serde_json::to_value(&parsed).unwrap(), minimal); - for key in ["threshold", "interval", "thresholdType"] { - let mut missing = minimal.clone(); - missing.as_object_mut().unwrap().remove(key); - assert!(serde_json::from_value::(missing.clone()).is_err()); - assert!(serde_json::from_value::(missing).is_err()); - } -} - -#[test] -fn clickstack_alert_response_carries_inline_chart_config_display_name_and_tags() { - let [_, (_, _, bar_raw), ..] = clickstack_alert_chart_config_payloads(); - let alert: ClickStackAlertResponse = serde_json::from_value(serde_json::json!({ - "id": "alert-1", "source": "inline", "chartConfig": bar_raw, - "displayName": "Errors by service", "tags": [], "state": "OK" - })) - .unwrap(); - assert_eq!(alert.source, Some(ClickStackAlertResponseSource::Inline)); - assert_eq!(alert.display_name.as_deref(), Some("Errors by service")); - assert_eq!(alert.tags, Some(vec![])); - assert!(matches!( - alert.chart_config, - Some( - ClickStackAlertChartConfigResponse::ClickStackAlertBarChartConfig( - ClickStackAlertBarChartConfigResponse::ClickStackAlertBarRawSqlChartConfig(_) - ) - ) - )); - - // The spec requires displayName and tags on the response, but a response - // that drops them or sends `null` still deserializes, to `None`. - let alert: ClickStackAlertResponse = serde_json::from_str( - r#"{"id":"alert-1","chartConfig":null,"displayName":null,"tags":null}"#, - ) - .unwrap(); - assert_eq!(alert.chart_config, None); - assert_eq!(alert.display_name, None); - assert_eq!(alert.tags, None); - assert_eq!( - serde_json::to_value(&alert).unwrap(), - serde_json::json!({"id": "alert-1"}) - ); -} diff --git a/crates/clickhouse-openapi-analyzer/src/lib.rs b/crates/clickhouse-openapi-analyzer/src/lib.rs index 02a6d37c..06bb7a54 100644 --- a/crates/clickhouse-openapi-analyzer/src/lib.rs +++ b/crates/clickhouse-openapi-analyzer/src/lib.rs @@ -180,11 +180,12 @@ pub fn model_types(rust_source_root: &Path) -> Result, AnalyzeE /// /// Backs the completeness half of /// `discriminated_union_defaults_round_trip_to_the_same_variant` in -/// `clickhouse-cloud-api`'s `models_test.rs`: a `discriminated_union!` enum's -/// `Default` must round-trip to the same variant, and every union with a -/// `Default` uses a manual impl (derived `Default` cannot pick a payload -/// variant), so requiring the test's covered-type list to equal this set means -/// a new manual `Default` impl cannot silently escape the invariant. Like +/// `clickhouse-cloud-api`'s `tests/models/cross_domain.rs` (the `models_test` +/// binary): a `discriminated_union!` enum's `Default` must round-trip to the +/// same variant, and every union with a `Default` uses a manual impl (derived +/// `Default` cannot pick a payload variant), so requiring the test's +/// covered-type list to equal this set means a new manual `Default` impl +/// cannot silently escape the invariant. Like /// [`model_fields_with_serde_default`], this is a repository-policy check /// rather than a drift `FindingKind`, and it keeps `syn` out of the published /// crate's dependency graph. diff --git a/scripts/classify-cloud-integration.py b/scripts/classify-cloud-integration.py index c28f4806..a32777b4 100644 --- a/scripts/classify-cloud-integration.py +++ b/scripts/classify-cloud-integration.py @@ -171,6 +171,23 @@ ), "crates/clickhouse-cloud-api/tests/integration_test.rs": frozenset({"service"}), "crates/clickhouse-cloud-api/tests/model_facade_test.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/activity.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/api_keys.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/backups.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/byoc.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/clickpipes.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/clickstack.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/cross_domain.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/main.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/organizations.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/postgres.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/query_api_endpoints.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/saved_queries.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/scim.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/services.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/shared.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/udfs.rs": NO_SUITES, + "crates/clickhouse-cloud-api/tests/models/whoami.rs": NO_SUITES, "crates/clickhouse-cloud-api/tests/models_test.rs": NO_SUITES, "crates/clickhouse-cloud-api/tests/run_query_test.rs": NO_SUITES, "crates/clickhouse-cloud-api/tests/service_query_key_cli_test.rs": frozenset( diff --git a/scripts/tests/test_classify_cloud_integration.py b/scripts/tests/test_classify_cloud_integration.py index e839cc57..015dd47d 100644 --- a/scripts/tests/test_classify_cloud_integration.py +++ b/scripts/tests/test_classify_cloud_integration.py @@ -211,7 +211,23 @@ def test_current_test_mapping_values(self): "crates/clickhouse-cloud-api/tests/clickpipes/stages/s3.rs", "crates/clickhouse-cloud-api/tests/client_test.rs", "crates/clickhouse-cloud-api/tests/model_facade_test.rs", - "crates/clickhouse-cloud-api/tests/models_test.rs", + "crates/clickhouse-cloud-api/tests/models/activity.rs", + "crates/clickhouse-cloud-api/tests/models/api_keys.rs", + "crates/clickhouse-cloud-api/tests/models/backups.rs", + "crates/clickhouse-cloud-api/tests/models/byoc.rs", + "crates/clickhouse-cloud-api/tests/models/clickpipes.rs", + "crates/clickhouse-cloud-api/tests/models/clickstack.rs", + "crates/clickhouse-cloud-api/tests/models/cross_domain.rs", + "crates/clickhouse-cloud-api/tests/models/main.rs", + "crates/clickhouse-cloud-api/tests/models/organizations.rs", + "crates/clickhouse-cloud-api/tests/models/postgres.rs", + "crates/clickhouse-cloud-api/tests/models/query_api_endpoints.rs", + "crates/clickhouse-cloud-api/tests/models/saved_queries.rs", + "crates/clickhouse-cloud-api/tests/models/scim.rs", + "crates/clickhouse-cloud-api/tests/models/services.rs", + "crates/clickhouse-cloud-api/tests/models/shared.rs", + "crates/clickhouse-cloud-api/tests/models/udfs.rs", + "crates/clickhouse-cloud-api/tests/models/whoami.rs", "crates/clickhouse-cloud-api/tests/run_query_test.rs", "crates/clickhouse-cloud-api/tests/spec_coverage_test.rs", },