diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68ef239..bae0f25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,6 @@ jobs: clippy: name: Clippy runs-on: ubuntu-latest - environment: GitHub composable-delivery steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable @@ -40,10 +39,10 @@ jobs: - name: Configure git credentials for private repos env: - GH_TOKEN_BUILDS: ${{ secrets.GH_TOKEN_BUILDS }} + KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }} run: | git config --global credential.helper store - echo "https://${GH_TOKEN_BUILDS}:x-oauth-basic@github.com" > ~/.git-credentials + echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials - run: cargo clippy --workspace --all-targets --all-features -- -D warnings @@ -51,7 +50,6 @@ jobs: test: name: Test runs-on: ubuntu-latest - environment: GitHub composable-delivery steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable @@ -60,10 +58,10 @@ jobs: - name: Configure git credentials for private repos env: - GH_TOKEN_BUILDS: ${{ secrets.GH_TOKEN_BUILDS }} + KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }} run: | git config --global credential.helper store - echo "https://${GH_TOKEN_BUILDS}:x-oauth-basic@github.com" > ~/.git-credentials + echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials - name: Clean coverage artifacts run: cargo llvm-cov clean --workspace @@ -96,7 +94,6 @@ jobs: docs: name: Documentation runs-on: ubuntu-latest - environment: GitHub composable-delivery steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable @@ -104,10 +101,10 @@ jobs: - name: Configure git credentials for private repos env: - GH_TOKEN_BUILDS: ${{ secrets.GH_TOKEN_BUILDS }} + KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }} run: | git config --global credential.helper store - echo "https://${GH_TOKEN_BUILDS}:x-oauth-basic@github.com" > ~/.git-credentials + echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials - run: cargo doc --workspace --no-deps --all-features env: @@ -123,6 +120,14 @@ jobs: with: toolchain: "1.88" - uses: Swatinem/rust-cache@v2 + + - name: Configure git credentials for private repos + env: + KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }} + run: | + git config --global credential.helper store + echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials + - run: cargo check --workspace # ── Integration tests (real Salesforce org, excluding WASM bridge) ── @@ -138,10 +143,10 @@ jobs: - name: Configure git credentials for private repos env: - GH_TOKEN_BUILDS: ${{ secrets.GH_TOKEN_BUILDS }} + KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }} run: | git config --global credential.helper store - echo "https://${GH_TOKEN_BUILDS}:x-oauth-basic@github.com" > ~/.git-credentials + echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials - name: Verify credentials env: @@ -200,10 +205,10 @@ jobs: - name: Configure git credentials for private repos env: - GH_TOKEN_BUILDS: ${{ secrets.GH_TOKEN_BUILDS }} + KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }} run: | git config --global credential.helper store - echo "https://${GH_TOKEN_BUILDS}:x-oauth-basic@github.com" > ~/.git-credentials + echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials - name: Verify credentials env: diff --git a/config/project-scratch-def.json b/config/project-scratch-def.json index 9607ebe..693f034 100644 --- a/config/project-scratch-def.json +++ b/config/project-scratch-def.json @@ -4,6 +4,7 @@ "description": "Scratch org for busbar-sf-api integration tests. Create with: sf org create scratch -f config/project-scratch-def.json -a busbar-test --duration-days 7", "features": [ "Knowledge", - "ServiceCloud" + "ServiceCloud", + "DataCloud" ] } diff --git a/crates/sf-auth/src/lib.rs b/crates/sf-auth/src/lib.rs index 7239777..d31cb35 100644 --- a/crates/sf-auth/src/lib.rs +++ b/crates/sf-auth/src/lib.rs @@ -47,7 +47,9 @@ mod storage; pub use credentials::{Credentials, SalesforceCredentials}; pub use error::{Error, ErrorKind, Result}; pub use jwt::JwtAuth; -pub use oauth::{OAuthClient, OAuthConfig, TokenInfo, TokenResponse, WebFlowAuth}; +pub use oauth::{ + DataCloudTokenResponse, OAuthClient, OAuthConfig, TokenInfo, TokenResponse, WebFlowAuth, +}; pub use storage::{FileTokenStorage, TokenStorage}; /// Default Salesforce login URL for production. diff --git a/crates/sf-auth/src/oauth.rs b/crates/sf-auth/src/oauth.rs index 419eae1..4101bc6 100644 --- a/crates/sf-auth/src/oauth.rs +++ b/crates/sf-auth/src/oauth.rs @@ -232,6 +232,80 @@ impl OAuthClient { Ok(()) } + /// Exchange a Salesforce access token for a Data Cloud (Data 360 / TSE) token. + /// + /// Data Cloud endpoints use a different instance URL (the TSE URL) and a + /// separate access token obtained via this OAuth 2.0 Token Exchange flow + /// ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)). + /// + /// # Arguments + /// + /// * `sf_access_token` — A valid Salesforce access token for the connected org. + /// * `login_url` — The Salesforce login/instance URL + /// (e.g. `https://login.salesforce.com` or your org's My Domain URL). + /// + /// # Returns + /// + /// A [`DataCloudTokenResponse`] containing: + /// - `access_token` — The Data Cloud access token to use with `DataCloudClient`. + /// - `instance_url` — The TSE (Tenant Service Endpoint) base URL for Data Cloud calls. + /// + /// # Example + /// + /// ```no_run + /// # use busbar_sf_auth::{OAuthClient, OAuthConfig}; + /// # async fn example() -> Result<(), busbar_sf_auth::Error> { + /// let config = OAuthConfig::new("consumer_key"); + /// let client = OAuthClient::new(config); + /// + /// let dc_token = client + /// .exchange_for_data_cloud("sf_access_token", "https://myorg.my.salesforce.com") + /// .await?; + /// + /// println!("Data Cloud TSE URL: {}", dc_token.instance_url); + /// // Use dc_token.access_token + dc_token.instance_url with DataCloudClient + /// # Ok(()) + /// # } + /// ``` + /// + /// The `sf_access_token` parameter is not logged to prevent credential exposure. + #[instrument(skip(self, sf_access_token))] + pub async fn exchange_for_data_cloud( + &self, + sf_access_token: &str, + login_url: &str, + ) -> Result { + let params = [ + ("grant_type", "urn:salesforce:grant-type:external:cdp"), + ("subject_token", sf_access_token), + ( + "subject_token_type", + "urn:ietf:params:oauth:token-type:access_token", + ), + ]; + + let body = serde_urlencoded::to_string(params)?; + + let response = self + .http_client + .post(format!("{}/services/oauth2/token", login_url)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await?; + + if !response.status().is_success() { + let error: OAuthErrorResponse = response.json().await?; + return Err(Error::new(ErrorKind::OAuth { + error: error.error, + description: error.error_description, + })); + } + + let token: DataCloudTokenResponse = response.json().await?; + Ok(token) + } + /// Handle a token response, checking for errors. async fn handle_token_response(&self, response: reqwest::Response) -> Result { if !response.status().is_success() { @@ -431,6 +505,41 @@ pub struct TokenInfo { pub sub: Option, } +/// Response from a Data Cloud (Data 360) token exchange. +/// +/// Contains the Data Cloud access token and the TSE (Tenant Service Endpoint) +/// URL, which is the base URL for all Data Cloud API calls. +/// +/// Sensitive fields are redacted in Debug output. +#[derive(Clone, Deserialize, Serialize)] +pub struct DataCloudTokenResponse { + /// Data Cloud access token. Use this with the `DataCloudClient` from `busbar-sf-rest`. + pub access_token: String, + /// TSE (Tenant Service Endpoint) URL — the base URL for Data Cloud API calls. + pub instance_url: String, + /// Token type (usually "Bearer"). + #[serde(default)] + pub token_type: Option, + /// Issued at timestamp. + #[serde(default)] + pub issued_at: Option, + /// Signature for verification. + #[serde(default)] + pub signature: Option, +} + +impl std::fmt::Debug for DataCloudTokenResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DataCloudTokenResponse") + .field("access_token", &"[REDACTED]") + .field("instance_url", &self.instance_url) + .field("token_type", &self.token_type) + .field("issued_at", &self.issued_at) + .field("signature", &self.signature.as_ref().map(|_| "[REDACTED]")) + .finish() + } +} + /// OAuth error response. #[derive(Debug, Deserialize)] struct OAuthErrorResponse { @@ -644,4 +753,92 @@ mod tests { "Error should mention revocation failed" ); } + + #[test] + fn test_data_cloud_token_response_debug_redacts_tokens() { + let response = DataCloudTokenResponse { + access_token: "super_secret_dc_token".to_string(), + instance_url: "https://something.c360a.salesforce.com".to_string(), + token_type: Some("Bearer".to_string()), + issued_at: Some("1234567890".to_string()), + signature: Some("signature_value".to_string()), + }; + + let debug_output = format!("{:?}", response); + assert!(debug_output.contains("[REDACTED]")); + assert!(!debug_output.contains("super_secret_dc_token")); + assert!(!debug_output.contains("signature_value")); + assert!(debug_output.contains("c360a.salesforce.com")); + } + + #[tokio::test] + async fn test_exchange_for_data_cloud_success() { + use wiremock::matchers::{body_string_contains, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/services/oauth2/token")) + .and(header("Content-Type", "application/x-www-form-urlencoded")) + .and(body_string_contains( + "grant_type=urn%3Asalesforce%3Agrant-type%3Aexternal%3Acdp", + )) + .and(body_string_contains("subject_token=test_sf_access_token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "dc_access_token_123", + "instance_url": "https://something.c360a.salesforce.com", + "token_type": "Bearer", + "issued_at": "1234567890" + }))) + .mount(&mock_server) + .await; + + let config = OAuthConfig::new("test_client_id"); + let client = OAuthClient::new(config); + + let result = client + .exchange_for_data_cloud("test_sf_access_token", &mock_server.uri()) + .await; + + assert!(result.is_ok(), "Token exchange should succeed"); + let dc_token = result.unwrap(); + assert_eq!(dc_token.access_token, "dc_access_token_123"); + assert_eq!( + dc_token.instance_url, + "https://something.c360a.salesforce.com" + ); + assert_eq!(dc_token.token_type, Some("Bearer".to_string())); + } + + #[tokio::test] + async fn test_exchange_for_data_cloud_failure() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/services/oauth2/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "Access token expired" + }))) + .mount(&mock_server) + .await; + + let config = OAuthConfig::new("test_client_id"); + let client = OAuthClient::new(config); + + let result = client + .exchange_for_data_cloud("expired_token", &mock_server.uri()) + .await; + + assert!(result.is_err(), "Token exchange should fail"); + let err = result.unwrap_err(); + assert!( + matches!(err.kind, ErrorKind::OAuth { .. }), + "Should return OAuth error" + ); + } } diff --git a/crates/sf-rest/src/client/data_cloud.rs b/crates/sf-rest/src/client/data_cloud.rs new file mode 100644 index 0000000..e172cba --- /dev/null +++ b/crates/sf-rest/src/client/data_cloud.rs @@ -0,0 +1,662 @@ +//! Data Cloud (Data 360) API client. +//! +//! [`DataCloudClient`] provides typed access to Salesforce Data Cloud endpoints. +//! Because Data Cloud runs on a separate infrastructure (the TSE — Tenant Service +//! Endpoint), it uses a different base URL and a different access token from the +//! standard Salesforce REST API. +//! +//! ## Quickstart +//! +//! ```rust,ignore +//! use busbar_sf_auth::{OAuthClient, OAuthConfig}; +//! use busbar_sf_rest::DataCloudClient; +//! use busbar_sf_rest::data_cloud::DataCloudQueryRequest; +//! +//! // 1. Exchange your SF access token for a Data Cloud token. +//! let oauth = OAuthClient::new(OAuthConfig::new("consumer_key")); +//! let dc_token = oauth +//! .exchange_for_data_cloud(&sf_access_token, &sf_instance_url) +//! .await?; +//! +//! // 2. Build a DataCloudClient using the TSE URL and Data Cloud token. +//! let client = DataCloudClient::new(dc_token.instance_url, dc_token.access_token)?; +//! +//! // 3. Execute a SQL query. +//! let response = client +//! .query_sql(&DataCloudQueryRequest { +//! sql: "SELECT ssot__Id__c, ssot__Name__c FROM Individual__dlm LIMIT 10".into(), +//! page_size: None, +//! r#async: None, +//! }) +//! .await?; +//! ``` + +use tracing::instrument; + +use busbar_sf_client::{ClientConfig, SalesforceClient}; + +use crate::data_cloud::{ + AsyncQueryStatus, DataCloudMetadataResponse, DataCloudQueryRequest, DataCloudQueryResponse, + VectorSearchRequest, VectorSearchResponse, +}; +use crate::error::Result; + +/// Client for Salesforce Data Cloud (Data 360) APIs. +/// +/// Data Cloud endpoints are hosted on a separate infrastructure called the +/// TSE (Tenant Service Endpoint). This client wraps a [`SalesforceClient`] pointed at +/// the TSE URL with a Data Cloud access token. +/// +/// Obtain the TSE URL and Data Cloud token by calling +/// [`OAuthClient::exchange_for_data_cloud`](busbar_sf_auth::OAuthClient::exchange_for_data_cloud) +/// with a valid Salesforce access token. +/// +/// # Example +/// +/// ```rust,ignore +/// use busbar_sf_rest::DataCloudClient; +/// use busbar_sf_rest::data_cloud::DataCloudQueryRequest; +/// +/// let client = DataCloudClient::new( +/// "https://something.c360a.salesforce.com", +/// "data_cloud_access_token", +/// )?; +/// +/// let result = client +/// .query_sql(&DataCloudQueryRequest { +/// sql: "SELECT ssot__Id__c FROM Individual__dlm LIMIT 5".into(), +/// page_size: None, +/// r#async: None, +/// }) +/// .await?; +/// ``` +#[derive(Debug, Clone)] +pub struct DataCloudClient { + client: SalesforceClient, + /// Data Cloud API version (without "v" prefix, e.g. `"64.0"`). + api_version: String, +} + +impl DataCloudClient { + /// Default API version for Data Cloud endpoints. + const DEFAULT_DC_API_VERSION: &'static str = "64.0"; + + /// Create a new Data Cloud client. + /// + /// # Arguments + /// + /// * `tse_url` — The TSE (Tenant Service Endpoint) base URL returned by the token exchange. + /// * `access_token` — The Data Cloud access token returned by the token exchange. + pub fn new(tse_url: impl Into, access_token: impl Into) -> Result { + let client = SalesforceClient::new(tse_url, access_token)?; + Ok(Self { + client: client.with_api_version(Self::DEFAULT_DC_API_VERSION), + api_version: Self::DEFAULT_DC_API_VERSION.to_string(), + }) + } + + /// Create a new Data Cloud client with custom HTTP configuration. + pub fn with_config( + tse_url: impl Into, + access_token: impl Into, + config: ClientConfig, + ) -> Result { + let client = SalesforceClient::with_config(tse_url, access_token, config)?; + Ok(Self { + client: client.with_api_version(Self::DEFAULT_DC_API_VERSION), + api_version: Self::DEFAULT_DC_API_VERSION.to_string(), + }) + } + + /// Override the Data Cloud API version (default: `"64.0"`). + pub fn with_api_version(mut self, version: impl Into) -> Self { + let version = version.into(); + self.client = self.client.with_api_version(version.clone()); + self.api_version = version; + self + } + + /// Get the TSE (Tenant Service Endpoint) URL. + pub fn tse_url(&self) -> &str { + self.client.instance_url() + } + + /// Get the current API version. + pub fn api_version(&self) -> &str { + &self.api_version + } + + // ========================================================================= + // SQL Query API (/services/data/v{version}/ssot/query-sql) + // ========================================================================= + + /// Execute a synchronous or asynchronous Data Cloud SQL query. + /// + /// Executes ANSI SQL against Data Model Objects (DMOs). For large result sets, + /// set `request.r#async = Some(true)` to receive a `queryId` and poll with + /// [`query_status`](Self::query_status) / [`query_rows`](Self::query_rows). + /// + /// # Example + /// + /// ```rust,ignore + /// let response = client + /// .query_sql(&DataCloudQueryRequest { + /// sql: "SELECT ssot__Id__c, ssot__Name__c FROM Individual__dlm LIMIT 100".into(), + /// page_size: Some(50), + /// r#async: None, + /// }) + /// .await?; + /// + /// for row in &response.data { + /// println!("{}", row); + /// } + /// ``` + #[instrument(skip(self, request))] + pub async fn query_sql( + &self, + request: &DataCloudQueryRequest, + ) -> Result { + let url = format!( + "{}/services/data/v{}/ssot/query-sql", + self.client.instance_url(), + self.api_version + ); + self.client + .post_json(&url, request) + .await + .map_err(Into::into) + } + + /// Check the status of an asynchronous Data Cloud SQL query. + /// + /// Poll this after submitting an async query with `query_sql` (with `r#async: Some(true)`). + /// When `status` is `"success"`, fetch results with [`query_rows`](Self::query_rows). + #[instrument(skip(self))] + pub async fn query_status(&self, query_id: &str) -> Result { + let url = format!( + "{}/services/data/v{}/ssot/query-sql/{}", + self.client.instance_url(), + self.api_version, + urlencoding::encode(query_id) + ); + self.client.get_json(&url).await.map_err(Into::into) + } + + /// Fetch the result rows of a completed asynchronous Data Cloud SQL query. + /// + /// Call this after [`query_status`](Self::query_status) returns `"success"`. + /// The response uses the same [`DataCloudQueryResponse`] shape as synchronous queries. + #[instrument(skip(self))] + pub async fn query_rows(&self, query_id: &str) -> Result { + let url = format!( + "{}/services/data/v{}/ssot/query-sql/{}/rows", + self.client.instance_url(), + self.api_version, + urlencoding::encode(query_id) + ); + self.client.get_json(&url).await.map_err(Into::into) + } + + // ========================================================================= + // Vector Search API (/services/data/v{version}/ssot/search-vector) + // ========================================================================= + + /// Execute a vector (semantic) search for RAG grounding. + /// + /// Searches the specified vector index for chunks semantically similar to `queryText`. + /// Use the returned chunk IDs to fetch full records via [`query_sql`](Self::query_sql) + /// when needed. + /// + /// # Example + /// + /// ```rust,ignore + /// let results = client + /// .vector_search(&VectorSearchRequest { + /// index_name: "Knowledge_Articles_Index".into(), + /// query_text: "How do I reset my API key?".into(), + /// top_k: Some(5), + /// }) + /// .await?; + /// + /// for chunk in &results.results { + /// println!("score={:.3} id={} content={}", chunk.score, chunk.id, chunk.content); + /// } + /// ``` + #[instrument(skip(self, request))] + pub async fn vector_search( + &self, + request: &VectorSearchRequest, + ) -> Result { + let url = format!( + "{}/services/data/v{}/ssot/search-vector", + self.client.instance_url(), + self.api_version + ); + self.client + .post_json(&url, request) + .await + .map_err(Into::into) + } + + // ========================================================================= + // Unified Profile API (/api/v1/profile/{dataModelName}) + // ========================================================================= + + /// Look up a unified profile for a given Data Model Object (DMO). + /// + /// Returns the profile records matching the supplied OData-style `filters` + /// (e.g. `"[EmailAddress__c='user@example.com']"`). Pass `None` to retrieve + /// all accessible records (subject to server-side limits). + /// + /// # Arguments + /// + /// * `data_model_name` — The DMO API name, e.g. `"Individual__dlm"`. + /// * `filters` — Optional OData filter expression, e.g. `[EmailAddress__c='user@example.com']`. + /// + /// # Example + /// + /// ```rust,ignore + /// let profile = client + /// .profile("Individual__dlm", Some("[ssot__EmailAddress__c='user@example.com']")) + /// .await?; + /// ``` + #[instrument(skip(self))] + pub async fn profile( + &self, + data_model_name: &str, + filters: Option<&str>, + ) -> Result { + let base = format!( + "{}/api/v1/profile/{}", + self.client.instance_url(), + urlencoding::encode(data_model_name) + ); + let url = match filters { + Some(f) => format!("{}?filters={}", base, urlencoding::encode(f)), + None => base, + }; + self.client.get_json(&url).await.map_err(Into::into) + } + + // ========================================================================= + // Metadata Discovery API (/api/v1/metadata) + // ========================================================================= + + /// Discover Data Cloud metadata entities (DMOs, fields, relationships). + /// + /// Optionally filter by `entity_type` (e.g. `"DataModelObject"`) to limit results + /// to a specific category of metadata. Use the returned field and relationship + /// information to validate queries or build schema-aware tooling. + /// + /// # Arguments + /// + /// * `entity_type` — Optional entity type filter, e.g. `"DataModelObject"`. + /// + /// # Example + /// + /// ```rust,ignore + /// let meta = client.metadata(Some("DataModelObject")).await?; + /// for obj in &meta.metadata { + /// println!("{}", obj.name); + /// } + /// ``` + #[instrument(skip(self))] + pub async fn metadata(&self, entity_type: Option<&str>) -> Result { + let base = format!("{}/api/v1/metadata", self.client.instance_url()); + let url = match entity_type { + Some(et) => format!("{}?entityType={}", base, urlencoding::encode(et)), + None => base, + }; + self.client.get_json(&url).await.map_err(Into::into) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_cloud::{ + ColumnInfo, DataCloudMetadataObject, DataCloudQueryRequest, QueryMetadata, + VectorSearchRequest, + }; + + #[test] + fn test_data_cloud_client_creation() { + let client = + DataCloudClient::new("https://something.c360a.salesforce.com", "dc_token").unwrap(); + assert_eq!(client.tse_url(), "https://something.c360a.salesforce.com"); + assert_eq!(client.api_version(), "64.0"); + } + + #[test] + fn test_api_version_override() { + let client = DataCloudClient::new("https://tse.salesforce.com", "token") + .unwrap() + .with_api_version("65.0"); + assert_eq!(client.api_version(), "65.0"); + } + + #[test] + fn test_query_request_serialization_sync() { + let req = DataCloudQueryRequest { + sql: "SELECT 1 FROM Individual__dlm".into(), + page_size: Some(100), + r#async: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["sql"], "SELECT 1 FROM Individual__dlm"); + assert_eq!(json["pageSize"], 100); + assert!( + json.get("async").is_none(), + "async should be omitted when None" + ); + } + + #[test] + fn test_query_request_serialization_async() { + let req = DataCloudQueryRequest { + sql: "SELECT 1 FROM Individual__dlm".into(), + page_size: None, + r#async: Some(true), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["async"], true); + assert!( + json.get("pageSize").is_none(), + "pageSize should be omitted when None" + ); + } + + #[test] + fn test_query_response_deserialization() { + let json = serde_json::json!({ + "data": [{"ssot__Id__c": "abc", "ssot__Name__c": "Alice"}], + "metadata": { + "columns": [ + {"name": "ssot__Id__c", "type": "varchar"}, + {"name": "ssot__Name__c", "type": "varchar"} + ] + }, + "done": true, + "queryId": "qry-123", + "nextBatchId": null + }); + let response: DataCloudQueryResponse = serde_json::from_value(json).unwrap(); + assert_eq!(response.data.len(), 1); + assert_eq!(response.metadata.columns.len(), 2); + assert_eq!(response.metadata.columns[0].name, "ssot__Id__c"); + assert_eq!(response.metadata.columns[0].col_type, "varchar"); + assert!(response.done); + assert_eq!(response.query_id, Some("qry-123".to_string())); + assert!(response.next_batch_id.is_none()); + } + + #[test] + fn test_column_info_deserialization() { + let json = + serde_json::json!({"name": "ssot__CreatedDate__c", "type": "timestamp_with_timezone"}); + let col: ColumnInfo = serde_json::from_value(json).unwrap(); + assert_eq!(col.name, "ssot__CreatedDate__c"); + assert_eq!(col.col_type, "timestamp_with_timezone"); + } + + #[test] + fn test_query_metadata_deserialization() { + let json = serde_json::json!({"columns": [{"name": "id", "type": "varchar"}]}); + let meta: QueryMetadata = serde_json::from_value(json).unwrap(); + assert_eq!(meta.columns.len(), 1); + } + + #[test] + fn test_async_query_status_deserialization() { + let json = serde_json::json!({ + "queryId": "qry-456", + "status": "success", + "errorMessage": null + }); + let status: AsyncQueryStatus = serde_json::from_value(json).unwrap(); + assert_eq!(status.query_id, "qry-456"); + assert_eq!(status.status, "success"); + assert!(status.error_message.is_none()); + } + + #[test] + fn test_vector_search_request_serialization() { + let req = VectorSearchRequest { + index_name: "Knowledge_Index".into(), + query_text: "How do I reset my password?".into(), + top_k: Some(5), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["indexName"], "Knowledge_Index"); + assert_eq!(json["queryText"], "How do I reset my password?"); + assert_eq!(json["topK"], 5); + } + + #[test] + fn test_vector_search_response_deserialization() { + let json = serde_json::json!({ + "results": [ + {"id": "rec-001", "score": 0.95, "content": "Reset steps..."}, + {"id": "rec-002", "score": 0.87, "content": "Password guide..."} + ] + }); + let resp: VectorSearchResponse = serde_json::from_value(json).unwrap(); + assert_eq!(resp.results.len(), 2); + assert_eq!(resp.results[0].id, "rec-001"); + assert!((resp.results[0].score - 0.95).abs() < f64::EPSILON); + assert_eq!(resp.results[0].content, "Reset steps..."); + } + + #[test] + fn test_metadata_response_deserialization() { + let json = serde_json::json!({ + "metadata": [ + { + "name": "Individual__dlm", + "label": "Individual", + "entityType": "DataModelObject", + "fields": [{"name": "ssot__Id__c", "type": "varchar"}] + } + ] + }); + let resp: DataCloudMetadataResponse = serde_json::from_value(json).unwrap(); + assert_eq!(resp.metadata.len(), 1); + assert_eq!(resp.metadata[0].name, "Individual__dlm"); + assert_eq!( + resp.metadata[0].entity_type, + Some("DataModelObject".to_string()) + ); + } + + #[test] + fn test_metadata_object_optional_fields() { + let json = serde_json::json!({"name": "Contact__dlm"}); + let obj: DataCloudMetadataObject = serde_json::from_value(json).unwrap(); + assert_eq!(obj.name, "Contact__dlm"); + assert!(obj.label.is_none()); + assert!(obj.entity_type.is_none()); + assert!(obj.fields.is_none()); + } + + // ------------------------------------------------------------------------- + // Wiremock integration tests + // ------------------------------------------------------------------------- + + #[tokio::test] + async fn test_query_sql_wiremock() { + use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + let body = serde_json::json!({ + "data": [{"ssot__Id__c": "001", "ssot__Name__c": "Alice"}], + "metadata": {"columns": [{"name": "ssot__Id__c", "type": "varchar"}]}, + "done": true + }); + + Mock::given(method("POST")) + .and(path_regex(".*/ssot/query-sql$")) + .respond_with(ResponseTemplate::new(200).set_body_json(&body)) + .mount(&mock_server) + .await; + + let client = DataCloudClient::new(mock_server.uri(), "dc-token").unwrap(); + let result = client + .query_sql(&DataCloudQueryRequest { + sql: "SELECT ssot__Id__c FROM Individual__dlm".into(), + page_size: None, + r#async: None, + }) + .await + .expect("query_sql should succeed"); + + assert_eq!(result.data.len(), 1); + assert!(result.done); + } + + #[tokio::test] + async fn test_query_status_wiremock() { + use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + let body = serde_json::json!({ + "queryId": "qry-789", + "status": "success" + }); + + Mock::given(method("GET")) + .and(path_regex(".*/ssot/query-sql/qry-789$")) + .respond_with(ResponseTemplate::new(200).set_body_json(&body)) + .mount(&mock_server) + .await; + + let client = DataCloudClient::new(mock_server.uri(), "dc-token").unwrap(); + let status = client + .query_status("qry-789") + .await + .expect("query_status should succeed"); + + assert_eq!(status.query_id, "qry-789"); + assert_eq!(status.status, "success"); + } + + #[tokio::test] + async fn test_query_rows_wiremock() { + use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + let body = serde_json::json!({ + "data": [{"ssot__Id__c": "abc"}], + "metadata": {"columns": [{"name": "ssot__Id__c", "type": "varchar"}]}, + "done": true + }); + + Mock::given(method("GET")) + .and(path_regex(".*/ssot/query-sql/.*/rows$")) + .respond_with(ResponseTemplate::new(200).set_body_json(&body)) + .mount(&mock_server) + .await; + + let client = DataCloudClient::new(mock_server.uri(), "dc-token").unwrap(); + let result = client + .query_rows("qry-789") + .await + .expect("query_rows should succeed"); + + assert_eq!(result.data.len(), 1); + } + + #[tokio::test] + async fn test_vector_search_wiremock() { + use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + let body = serde_json::json!({ + "results": [ + {"id": "ka-001", "score": 0.92, "content": "To reset, go to Settings..."} + ] + }); + + Mock::given(method("POST")) + .and(path_regex(".*/ssot/search-vector$")) + .respond_with(ResponseTemplate::new(200).set_body_json(&body)) + .mount(&mock_server) + .await; + + let client = DataCloudClient::new(mock_server.uri(), "dc-token").unwrap(); + let result = client + .vector_search(&VectorSearchRequest { + index_name: "KnowledgeIndex".into(), + query_text: "How do I reset my password?".into(), + top_k: Some(3), + }) + .await + .expect("vector_search should succeed"); + + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].id, "ka-001"); + } + + #[tokio::test] + async fn test_profile_wiremock() { + use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + let body = serde_json::json!({ + "data": [{"ssot__Id__c": "ind-001", "ssot__Name__c": "Alice"}], + "done": true + }); + + Mock::given(method("GET")) + .and(path_regex(".*/api/v1/profile/.*")) + .respond_with(ResponseTemplate::new(200).set_body_json(&body)) + .mount(&mock_server) + .await; + + let client = DataCloudClient::new(mock_server.uri(), "dc-token").unwrap(); + let result = client + .profile("Individual__dlm", Some("[ssot__EmailAddress__c='a@b.com']")) + .await + .expect("profile should succeed"); + + assert!(result["data"].is_array()); + } + + #[tokio::test] + async fn test_metadata_wiremock() { + use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + let body = serde_json::json!({ + "metadata": [ + {"name": "Individual__dlm", "entityType": "DataModelObject"} + ] + }); + + Mock::given(method("GET")) + .and(path_regex(".*/api/v1/metadata.*")) + .respond_with(ResponseTemplate::new(200).set_body_json(&body)) + .mount(&mock_server) + .await; + + let client = DataCloudClient::new(mock_server.uri(), "dc-token").unwrap(); + let result = client + .metadata(Some("DataModelObject")) + .await + .expect("metadata should succeed"); + + assert_eq!(result.metadata.len(), 1); + assert_eq!(result.metadata[0].name, "Individual__dlm"); + } +} diff --git a/crates/sf-rest/src/client/mod.rs b/crates/sf-rest/src/client/mod.rs index 6b53cbf..eeafc37 100644 --- a/crates/sf-rest/src/client/mod.rs +++ b/crates/sf-rest/src/client/mod.rs @@ -8,11 +8,14 @@ use busbar_sf_client::{ClientConfig, SalesforceClient}; use crate::error::Result; +pub use data_cloud::DataCloudClient; + mod binary; mod collections; mod composite; mod consent; mod crud; +mod data_cloud; mod describe; mod embedded_service; mod invocable_actions; diff --git a/crates/sf-rest/src/data_cloud.rs b/crates/sf-rest/src/data_cloud.rs new file mode 100644 index 0000000..e81892c --- /dev/null +++ b/crates/sf-rest/src/data_cloud.rs @@ -0,0 +1,137 @@ +//! Data Cloud (Data 360) API types. +//! +//! These types support the Salesforce Data Cloud (formerly CDP/C360) API endpoints: +//! - SQL query via `/services/data/v{version}/ssot/query-sql` +//! - Vector search via `/services/data/v{version}/ssot/search-vector` +//! - Unified profile via `/api/v1/profile/{dataModelName}` +//! - Metadata discovery via `/api/v1/metadata` +//! +//! All Data Cloud calls go to the TSE (Tenant Service Endpoint) URL and require +//! a Data Cloud access token obtained via +//! [`OAuthClient::exchange_for_data_cloud`](busbar_sf_auth::OAuthClient::exchange_for_data_cloud). + +use serde::{Deserialize, Serialize}; + +/// Request body for a Data Cloud SQL query. +/// +/// Used with both synchronous and asynchronous query execution. +#[derive(Debug, Clone, Serialize)] +pub struct DataCloudQueryRequest { + /// ANSI SQL statement to execute against Data Model Objects (DMOs). + pub sql: String, + /// Number of rows to return per page. Defaults to the server default when `None`. + #[serde(rename = "pageSize", skip_serializing_if = "Option::is_none")] + pub page_size: Option, + /// When `true`, submit an asynchronous query and receive a `queryId` instead + /// of immediate results. Poll status with [`DataCloudClient::query_status`](crate::DataCloudClient::query_status) + /// and fetch rows with [`DataCloudClient::query_rows`](crate::DataCloudClient::query_rows). + #[serde(rename = "async", skip_serializing_if = "Option::is_none")] + pub r#async: Option, +} + +/// Metadata about a column in a Data Cloud query result. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ColumnInfo { + /// Column name (may retain original API casing as of 2026, e.g. `ssot__Id__c`). + pub name: String, + /// Column data type, e.g. `"numeric"`, `"varchar"`, `"timestamp_with_timezone"`. + #[serde(rename = "type")] + pub col_type: String, +} + +/// Metadata section of a Data Cloud query response. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct QueryMetadata { + /// Ordered list of columns returned by the query. + pub columns: Vec, +} + +/// Response from a synchronous Data Cloud SQL query. +/// +/// For asynchronous queries, the `query_id` field is populated and `data`/`metadata` +/// may be empty until the query completes. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DataCloudQueryResponse { + /// Result rows as a list of JSON objects (one per row). + pub data: Vec, + /// Column metadata for the result set. + pub metadata: QueryMetadata, + /// `true` when all rows have been returned (no more pages). + pub done: bool, + /// Query ID for async queries or for fetching subsequent pages. + #[serde(rename = "queryId", default)] + pub query_id: Option, + /// ID of the next batch when paginating large result sets. + #[serde(rename = "nextBatchId", default)] + pub next_batch_id: Option, +} + +/// Status of an asynchronous Data Cloud query. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AsyncQueryStatus { + /// Unique identifier for this async query. + #[serde(rename = "queryId")] + pub query_id: String, + /// Current status string, e.g. `"running"`, `"success"`, `"failed"`. + pub status: String, + /// Error message when `status` is `"failed"`. + #[serde(rename = "errorMessage", default)] + pub error_message: Option, +} + +/// Request body for a Data Cloud vector search (RAG). +/// +/// Used with the `/services/data/v{version}/ssot/search-vector` endpoint. +#[derive(Debug, Clone, Serialize)] +pub struct VectorSearchRequest { + /// Name of the vector search index to query. + #[serde(rename = "indexName")] + pub index_name: String, + /// Natural-language query text to embed and search with. + #[serde(rename = "queryText")] + pub query_text: String, + /// Maximum number of results to return. Defaults to the server default when `None`. + #[serde(rename = "topK", skip_serializing_if = "Option::is_none")] + pub top_k: Option, +} + +/// A single chunk returned by a vector search. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct VectorSearchResult { + /// Source record ID for the chunk. + pub id: String, + /// Similarity score (higher is more similar). + pub score: f64, + /// Text content of the matched chunk. + pub content: String, +} + +/// Response from a Data Cloud vector search. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct VectorSearchResponse { + /// Ordered list of matching chunks (most similar first). + pub results: Vec, +} + +/// A Data Model Object (DMO) or other metadata entity returned by the discovery API. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DataCloudMetadataObject { + /// API name of the entity. + pub name: String, + /// Display label. + #[serde(default)] + pub label: Option, + /// Entity type, e.g. `"DataModelObject"`. + #[serde(rename = "entityType", default)] + pub entity_type: Option, + /// Field definitions (structure varies by entity type). + #[serde(default)] + pub fields: Option>, +} + +/// Response from the Data Cloud metadata discovery API. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DataCloudMetadataResponse { + /// List of metadata entities. + pub metadata: Vec, +} diff --git a/crates/sf-rest/src/lib.rs b/crates/sf-rest/src/lib.rs index 7768b1b..93f3ed9 100644 --- a/crates/sf-rest/src/lib.rs +++ b/crates/sf-rest/src/lib.rs @@ -50,6 +50,7 @@ mod client; mod collections; mod composite; mod consent; +mod data_cloud; mod describe; mod embedded_service; mod error; @@ -69,7 +70,7 @@ mod user_password; // Main client pub use client::{ - ApiVersion, DeletedRecord, GetDeletedResult, GetUpdatedResult, SObjectInfo, + ApiVersion, DataCloudClient, DeletedRecord, GetDeletedResult, GetUpdatedResult, SObjectInfo, SObjectInfoDescribe, SalesforceRestClient, SearchResult, }; @@ -169,5 +170,12 @@ pub use scheduler::{ // PR #54: Embedded Service types pub use embedded_service::EmbeddedServiceConfig; +// Data Cloud (Data 360) types +pub use data_cloud::{ + AsyncQueryStatus, ColumnInfo, DataCloudMetadataObject, DataCloudMetadataResponse, + DataCloudQueryRequest, DataCloudQueryResponse, QueryMetadata, VectorSearchRequest, + VectorSearchResponse, VectorSearchResult, +}; + // Re-export sf-client types that users might need pub use busbar_sf_client::{ClientConfig, ClientConfigBuilder}; diff --git a/tests/integration.rs b/tests/integration.rs index 6a0a423..cd1459e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -11,6 +11,8 @@ mod bridge; mod bulk; #[path = "integration/common.rs"] mod common; +#[path = "integration/data_cloud.rs"] +mod data_cloud; #[path = "integration/metadata.rs"] mod metadata; #[path = "integration/rest.rs"] diff --git a/tests/integration/data_cloud.rs b/tests/integration/data_cloud.rs new file mode 100644 index 0000000..992fbae --- /dev/null +++ b/tests/integration/data_cloud.rs @@ -0,0 +1,412 @@ +//! Data Cloud (Data 360) integration tests. +//! +//! These tests require a Salesforce scratch org with the **DataCloud** feature +//! enabled (see `config/project-scratch-def.json`). The org credentials are +//! read from the `SF_AUTH_URL` environment variable, exactly as all other +//! integration tests do. +//! +//! The token exchange flow is tested first. If the connected org does not +//! have Data Cloud provisioned, the exchange returns an OAuth error and the +//! test fails with a clear message — consistent with the policy of never +//! silently skipping integration tests. +//! +//! To create a Data Cloud–enabled scratch org: +//! ```sh +//! sf org create scratch \ +//! -f config/project-scratch-def.json \ +//! -a busbar-dc-test \ +//! --duration-days 7 +//! +//! export SF_AUTH_URL=$( +//! sf org display \ +//! --target-org busbar-dc-test \ +//! --verbose --json \ +//! | jq -r '.result.sfdxAuthUrl' +//! ) +//! cargo test --test integration data_cloud:: -- --nocapture +//! ``` + +use super::common::get_credentials; +use busbar_sf_auth::{Credentials, OAuthClient, OAuthConfig}; +use busbar_sf_rest::{DataCloudClient, DataCloudQueryRequest, VectorSearchRequest}; + +/// Derive the Salesforce login URL from the instance URL. +/// +/// Scratch orgs use `test.salesforce.com`; production uses the standard +/// `login.salesforce.com`. +fn login_url_for_instance(instance_url: &str) -> String { + if instance_url.contains("test.salesforce.com") + || instance_url.contains("sandbox") + || instance_url.contains(".scratch.") + || instance_url.contains("--") + { + "https://test.salesforce.com".to_string() + } else { + "https://login.salesforce.com".to_string() + } +} + +/// Perform the Data Cloud token exchange and return a `DataCloudClient`. +/// +/// This is the entry-point for all Data Cloud tests. A failure here means the +/// scratch org does not have Data Cloud provisioned — re-create it with the +/// `DataCloud` feature enabled in `config/project-scratch-def.json`. +async fn get_data_cloud_client() -> DataCloudClient { + let creds = get_credentials().await; + let login_url = login_url_for_instance(creds.instance_url()); + + // The Data Cloud token exchange (RFC 8693 / Salesforce extension) does not + // require a `client_id` in the request body — the subject token identifies + // the caller. `OAuthConfig` is used only to obtain a configured HTTP client; + // the consumer key is intentionally left empty here. + let config = OAuthConfig::new(""); + let oauth = OAuthClient::new(config); + + let dc_token = oauth + .exchange_for_data_cloud(creds.access_token(), &login_url) + .await + .unwrap_or_else(|e| { + panic!( + "Data Cloud token exchange failed: {e}\n\ + \n\ + The scratch org may not have the DataCloud feature enabled.\n\ + Re-create the org with:\n\ + \n\ + sf org create scratch -f config/project-scratch-def.json -a busbar-dc-test --duration-days 7\n\ + \n\ + Ensure your DevHub has Salesforce Data Cloud licensed and enabled." + ) + }); + + assert!( + !dc_token.access_token.is_empty(), + "Data Cloud access token should not be empty" + ); + assert!( + !dc_token.instance_url.is_empty(), + "Data Cloud instance URL (TSE URL) should not be empty" + ); + + DataCloudClient::new(&dc_token.instance_url, &dc_token.access_token) + .expect("Failed to create DataCloudClient from token exchange response") +} + +/// Validate that a Data Model Object name is a safe SQL identifier. +/// +/// DMO names returned by the metadata API are Salesforce identifiers: they +/// consist of letters, digits, and underscores only. Reject anything that +/// doesn't match to prevent SQL injection in tests that interpolate the name +/// into a query string. +fn validate_dmo_name(name: &str) -> &str { + assert!( + !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_'), + "DMO name '{name}' contains characters that are not valid in a SQL identifier" + ); + name +} + +// ============================================================================ +// Token Exchange +// ============================================================================ + +/// Verify that the Data Cloud OAuth 2.0 token exchange succeeds and returns +/// a non-empty TSE (Tenant Service Endpoint) URL and access token. +#[tokio::test] +async fn test_data_cloud_token_exchange_returns_tse_url_and_token() { + let creds = get_credentials().await; + let login_url = login_url_for_instance(creds.instance_url()); + + // The Data Cloud token exchange does not require a `client_id` — the + // subject token is sufficient. `OAuthConfig` is used only as a handle + // to the configured HTTP client; the consumer key is left empty. + let config = OAuthConfig::new(""); + let oauth = OAuthClient::new(config); + + let dc_token = oauth + .exchange_for_data_cloud(creds.access_token(), &login_url) + .await + .unwrap_or_else(|e| { + panic!( + "Data Cloud token exchange failed: {e}\n\ + Ensure the scratch org has the DataCloud feature enabled." + ) + }); + + assert!( + !dc_token.access_token.is_empty(), + "Data Cloud access token must not be empty" + ); + assert!( + !dc_token.instance_url.is_empty(), + "Data Cloud TSE URL must not be empty" + ); + assert_eq!( + dc_token.token_type.as_deref(), + Some("Bearer"), + "token_type should be Bearer" + ); + + println!("Data Cloud TSE URL: {}", dc_token.instance_url); +} + +// ============================================================================ +// Metadata Discovery +// ============================================================================ + +/// Call the Data Cloud metadata discovery endpoint and verify it returns +/// at least one metadata entity when filtered by `DataModelObject`. +#[tokio::test] +async fn test_data_cloud_metadata_discovery_returns_entities() { + let client = get_data_cloud_client().await; + + let result = client + .metadata(Some("DataModelObject")) + .await + .unwrap_or_else(|e| { + panic!( + "Data Cloud metadata discovery failed: {e}\n\ + Ensure the scratch org has DataCloud provisioned and the TSE URL is reachable." + ) + }); + + // A Data Cloud-enabled org always has at least the core system DMOs. + assert!( + !result.metadata.is_empty(), + "Expected at least one DataModelObject in metadata response, got 0.\n\ + This might indicate Data Cloud data streams have not been configured." + ); + + for obj in &result.metadata { + assert!( + !obj.name.is_empty(), + "Each metadata object should have a non-empty name" + ); + } + + println!("Data Cloud metadata: {} object(s)", result.metadata.len()); + for obj in result.metadata.iter().take(5) { + println!( + " - {} (entityType: {})", + obj.name, + obj.entity_type.as_deref().unwrap_or("unknown") + ); + } +} + +/// Call the metadata endpoint without an entity type filter. +#[tokio::test] +async fn test_data_cloud_metadata_discovery_without_filter() { + let client = get_data_cloud_client().await; + + let result = client + .metadata(None) + .await + .unwrap_or_else(|e| panic!("Data Cloud metadata (no filter) failed: {e}")); + + // No filter = broader results; should have at least some entities. + println!( + "Data Cloud metadata (no filter): {} entity(ies)", + result.metadata.len() + ); +} + +// ============================================================================ +// SQL Query +// ============================================================================ + +/// Execute a simple SQL query against Data Cloud. We use `LIMIT 0` so the test +/// doesn't depend on actual data being loaded, while still exercising the +/// query parsing and column-metadata response. +#[tokio::test] +async fn test_data_cloud_sql_query_returns_column_metadata() { + let client = get_data_cloud_client().await; + + // First discover a real DMO name so we don't hard-code one. + let meta = client + .metadata(Some("DataModelObject")) + .await + .unwrap_or_else(|e| panic!("Metadata discovery failed: {e}")); + + if meta.metadata.is_empty() { + panic!( + "No DataModelObjects found in the scratch org.\n\ + Data Cloud data streams must be configured before running SQL tests." + ); + } + + let dmo_name = validate_dmo_name(&meta.metadata[0].name); + println!("Querying DMO: {dmo_name}"); + + let request = DataCloudQueryRequest { + sql: format!("SELECT * FROM {dmo_name} LIMIT 0"), + page_size: Some(10), + r#async: None, + }; + + let response = client.query_sql(&request).await.unwrap_or_else(|e| { + panic!( + "Data Cloud SQL query failed for DMO '{dmo_name}': {e}\n\ + Ensure the DMO exists and the Data Cloud access token has sufficient permissions." + ) + }); + + // With LIMIT 0, data should be empty but metadata must be present. + assert!(response.done, "LIMIT 0 query should be done immediately"); + assert!( + !response.metadata.columns.is_empty(), + "SQL response metadata should include at least one column for DMO '{dmo_name}'" + ); + + println!( + "DMO '{}' has {} column(s): {}", + dmo_name, + response.metadata.columns.len(), + response + .metadata + .columns + .iter() + .map(|c| format!("{}:{}", c.name, c.col_type)) + .collect::>() + .join(", ") + ); +} + +/// Test that an asynchronous query submission returns a query ID and can be +/// polled for status. +#[tokio::test] +async fn test_data_cloud_async_sql_query_returns_query_id() { + let client = get_data_cloud_client().await; + + let meta = client + .metadata(Some("DataModelObject")) + .await + .unwrap_or_else(|e| panic!("Metadata discovery failed: {e}")); + + if meta.metadata.is_empty() { + panic!("No DataModelObjects found; cannot run async SQL test."); + } + + let dmo_name = validate_dmo_name(&meta.metadata[0].name); + + let request = DataCloudQueryRequest { + sql: format!("SELECT * FROM {dmo_name} LIMIT 1"), + page_size: Some(10), + r#async: Some(true), + }; + + let response = client + .query_sql(&request) + .await + .unwrap_or_else(|e| panic!("Async Data Cloud SQL query failed: {e}")); + + // For async queries the server may return a queryId immediately. + // Some implementations return results synchronously even when async=true. + if let Some(query_id) = &response.query_id { + println!("Async query ID: {query_id}"); + + // Poll status (one round is sufficient to verify the endpoint works). + let status = client + .query_status(query_id) + .await + .unwrap_or_else(|e| panic!("query_status failed for {query_id}: {e}")); + + assert!( + !status.status.is_empty(), + "Query status should have a non-empty status string" + ); + println!("Async query status: {}", status.status); + + // If the query succeeded, fetch the rows. + if status.status == "success" { + let rows = client + .query_rows(query_id) + .await + .unwrap_or_else(|e| panic!("query_rows failed for {query_id}: {e}")); + println!("Async query rows: {}", rows.data.len()); + } + } else { + // Synchronous response even with async=true — this is acceptable. + println!("Server returned synchronous response (async=true was ignored)."); + assert!(response.done, "Synchronous response must have done=true"); + } +} + +// ============================================================================ +// Vector Search +// ============================================================================ + +/// Verify the vector search endpoint is reachable. In a scratch org without +/// indexed knowledge articles the call may return an empty result set or an +/// error indicating no index exists — both outcomes are acceptable here; we +/// only verify the HTTP round-trip completes without a network-level error. +#[tokio::test] +async fn test_data_cloud_vector_search_endpoint_is_reachable() { + let client = get_data_cloud_client().await; + + let request = VectorSearchRequest { + index_name: "Knowledge_Articles_Index".to_string(), + query_text: "How do I reset my password?".to_string(), + top_k: Some(3), + }; + + match client.vector_search(&request).await { + Ok(response) => { + println!( + "Vector search returned {} result(s)", + response.results.len() + ); + } + Err(e) => { + // An error here typically means the index doesn't exist in the + // scratch org — which is expected when no Knowledge articles have + // been indexed. Treat as a setup issue, not a code defect. + let err_str = e.to_string(); + println!("Vector search error (may indicate no index configured): {err_str}"); + assert!( + err_str.contains("not found") + || err_str.contains("does not exist") + || err_str.contains("NO_SUCH_INDEX") + || err_str.contains("invalid") + || err_str.contains("400") + || err_str.contains("404"), + "Unexpected vector search error: {err_str}\n\ + Expected an index-not-found type error for a fresh scratch org." + ); + } + } +} + +// ============================================================================ +// Profile Lookup +// ============================================================================ + +/// Call the unified profile endpoint. In a fresh scratch org without Data +/// Cloud data streams configured, this returns an empty result or a DMO-not- +/// found error. We verify the endpoint is reachable and the client handles +/// both outcomes correctly. +#[tokio::test] +async fn test_data_cloud_profile_endpoint_is_reachable() { + let client = get_data_cloud_client().await; + + match client.profile("Individual__dlm", None).await { + Ok(response) => { + println!( + "Profile response keys: {:?}", + response.as_object().map(|m| m.keys().collect::>()) + ); + } + Err(e) => { + let err_str = e.to_string(); + println!("Profile error (may indicate no Individual__dlm configured): {err_str}"); + // A 404 / not-found error is expected in scratch orgs without data streams. + assert!( + err_str.contains("404") + || err_str.contains("not found") + || err_str.contains("NOT_FOUND") + || err_str.contains("invalid") + || err_str.contains("400"), + "Unexpected profile error: {err_str}" + ); + } + } +}