cirrus-metadata 0.1.0

Salesforce Metadata API (SOAP) client for the Cirrus SDK.
Documentation
//! Wiremock-backed tests for the utility Metadata API handlers.
//!
//! Covers `list_metadata`, `describe_metadata`, and
//! `describe_value_type` happy paths plus the client-side query-cap
//! check on `list_metadata`. Response fixtures are modeled after the
//! documented examples in the Metadata API Developer Guide.

#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use cirrus_metadata::auth::StaticTokenAuth;
use cirrus_metadata::{ListMetadataQuery, MetadataClient, MetadataError, RetryPolicy};
use std::sync::Arc;
use std::time::Duration;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn xml_response(body: &str) -> ResponseTemplate {
    ResponseTemplate::new(200)
        .insert_header("content-type", "text/xml; charset=UTF-8")
        .set_body_string(body.to_string())
}

fn client_against(server: &MockServer) -> MetadataClient {
    let auth = Arc::new(StaticTokenAuth::new("tok", server.uri()));
    MetadataClient::builder()
        .auth(auth)
        .retry_policy(RetryPolicy {
            base_delay: Duration::from_millis(1),
            max_delay: Duration::from_millis(5),
            jitter: false,
            ..RetryPolicy::default()
        })
        .build()
        .unwrap()
}

// -- list_metadata -----------------------------------------------------------

#[tokio::test]
async fn list_metadata_returns_file_properties_for_each_match() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(path("/services/Soap/m/66.0"))
        .and(body_string_contains("<met:listMetadata>"))
        .and(body_string_contains("<met:type>ApexClass</met:type>"))
        .and(body_string_contains(
            "<met:asOfVersion>66.0</met:asOfVersion>",
        ))
        .respond_with(xml_response(
            r#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
  <soapenv:Body>
    <listMetadataResponse xmlns="http://soap.sforce.com/2006/04/metadata">
      <result>
        <createdById>005xx0000abc</createdById>
        <createdByName>Stephanie</createdByName>
        <createdDate>2026-01-15T08:00:00.000Z</createdDate>
        <fileName>classes/Foo.cls</fileName>
        <fullName>Foo</fullName>
        <id>01p00000abcDEF</id>
        <lastModifiedById>005xx0000abc</lastModifiedById>
        <lastModifiedByName>Stephanie</lastModifiedByName>
        <lastModifiedDate>2026-05-20T12:00:00.000Z</lastModifiedDate>
        <type>ApexClass</type>
      </result>
      <result>
        <createdById>005xx0000abc</createdById>
        <createdByName>Stephanie</createdByName>
        <createdDate>2026-02-10T08:00:00.000Z</createdDate>
        <fileName>classes/Bar.cls</fileName>
        <fullName>Bar</fullName>
        <id>01p00000xyzGHI</id>
        <lastModifiedById>005xx0000abc</lastModifiedById>
        <lastModifiedByName>Stephanie</lastModifiedByName>
        <lastModifiedDate>2026-04-01T09:30:00.000Z</lastModifiedDate>
        <type>ApexClass</type>
      </result>
    </listMetadataResponse>
  </soapenv:Body>
</soapenv:Envelope>"#,
        ))
        .mount(&server)
        .await;

    let md = client_against(&server);
    let results = md
        .list_metadata(
            vec![ListMetadataQuery {
                type_name: "ApexClass".into(),
                folder: None,
            }],
            "66.0",
        )
        .await
        .unwrap();
    assert_eq!(results.len(), 2);
    assert_eq!(results[0].full_name, "Foo");
    assert_eq!(results[0].type_name, Some("ApexClass".into()));
    assert_eq!(results[1].full_name, "Bar");
}

#[tokio::test]
async fn list_metadata_empty_results_deserialize_as_empty_vec() {
    let server = MockServer::start().await;

    // Empty-response shape — Salesforce returns the wrapper with no
    // <result> children when there's nothing matching.
    Mock::given(method("POST"))
        .respond_with(xml_response(
            r#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
  <soapenv:Body>
    <listMetadataResponse xmlns="http://soap.sforce.com/2006/04/metadata">
    </listMetadataResponse>
  </soapenv:Body>
</soapenv:Envelope>"#,
        ))
        .mount(&server)
        .await;

    let md = client_against(&server);
    let results = md
        .list_metadata(
            vec![ListMetadataQuery {
                type_name: "CustomObject".into(),
                folder: None,
            }],
            "66.0",
        )
        .await
        .unwrap();
    assert!(results.is_empty());
}

#[tokio::test]
async fn list_metadata_emits_folder_for_folder_based_types() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(body_string_contains(
            "<met:folder>SharedDashboards</met:folder>",
        ))
        .and(body_string_contains("<met:type>Dashboard</met:type>"))
        .respond_with(xml_response(
            r#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
  <soapenv:Body>
    <listMetadataResponse xmlns="http://soap.sforce.com/2006/04/metadata"/>
  </soapenv:Body>
</soapenv:Envelope>"#,
        ))
        .mount(&server)
        .await;

    let md = client_against(&server);
    let _ = md
        .list_metadata(
            vec![ListMetadataQuery {
                type_name: "Dashboard".into(),
                folder: Some("SharedDashboards".into()),
            }],
            "66.0",
        )
        .await
        .unwrap();
}

#[tokio::test]
async fn list_metadata_rejects_more_than_three_queries() {
    // No mock server needed — the rejection happens client-side.
    let auth = Arc::new(StaticTokenAuth::new("tok", "https://x.example.com"));
    let md = MetadataClient::builder().auth(auth).build().unwrap();

    let queries: Vec<_> = (0..4)
        .map(|i| ListMetadataQuery {
            type_name: format!("Type{i}"),
            folder: None,
        })
        .collect();

    let err = md.list_metadata(queries, "66.0").await.unwrap_err();
    match err {
        MetadataError::InvalidArgument(msg) => {
            assert!(msg.contains("3"));
            assert!(msg.contains("4"));
        }
        other => panic!("expected InvalidArgument, got {other:?}"),
    }
}

// -- describe_metadata -------------------------------------------------------

#[tokio::test]
async fn describe_metadata_parses_object_catalog() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(body_string_contains("<met:describeMetadata>"))
        .and(body_string_contains(
            "<met:asOfVersion>66.0</met:asOfVersion>",
        ))
        .respond_with(xml_response(
            r#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
  <soapenv:Body>
    <describeMetadataResponse xmlns="http://soap.sforce.com/2006/04/metadata">
      <result>
        <metadataObjects>
          <directoryName>classes</directoryName>
          <inFolder>false</inFolder>
          <metaFile>true</metaFile>
          <suffix>cls</suffix>
          <xmlName>ApexClass</xmlName>
        </metadataObjects>
        <metadataObjects>
          <childXmlNames>CustomField</childXmlNames>
          <childXmlNames>ValidationRule</childXmlNames>
          <directoryName>objects</directoryName>
          <inFolder>false</inFolder>
          <metaFile>false</metaFile>
          <suffix>object</suffix>
          <xmlName>CustomObject</xmlName>
        </metadataObjects>
        <metadataObjects>
          <directoryName>dashboards</directoryName>
          <inFolder>true</inFolder>
          <metaFile>false</metaFile>
          <suffix>dashboard</suffix>
          <xmlName>Dashboard</xmlName>
        </metadataObjects>
        <organizationNamespace></organizationNamespace>
        <partialSaveAllowed>true</partialSaveAllowed>
        <testRequired>false</testRequired>
      </result>
    </describeMetadataResponse>
  </soapenv:Body>
</soapenv:Envelope>"#,
        ))
        .mount(&server)
        .await;

    let md = client_against(&server);
    let result = md.describe_metadata("66.0").await.unwrap();
    assert_eq!(result.metadata_objects.len(), 3);
    assert!(result.partial_save_allowed);
    assert!(!result.test_required);

    let apex = &result.metadata_objects[0];
    assert_eq!(apex.xml_name, "ApexClass");
    assert_eq!(apex.directory_name, Some("classes".into()));
    assert_eq!(apex.suffix, Some("cls".into()));
    assert!(!apex.in_folder);
    assert!(apex.meta_file);

    let custom_obj = &result.metadata_objects[1];
    assert_eq!(custom_obj.xml_name, "CustomObject");
    assert_eq!(
        custom_obj.child_xml_names,
        vec!["CustomField".to_string(), "ValidationRule".to_string()]
    );

    let dashboard = &result.metadata_objects[2];
    assert!(dashboard.in_folder);
}

// -- describe_value_type -----------------------------------------------------

#[tokio::test]
async fn describe_value_type_parses_field_schema() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(body_string_contains("<met:describeValueType>"))
        .and(body_string_contains(
            "<met:type>{http://soap.sforce.com/2006/04/metadata}ApexClass</met:type>",
        ))
        .respond_with(xml_response(
            r#"<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
  <soapenv:Body>
    <describeValueTypeResponse xmlns="http://soap.sforce.com/2006/04/metadata">
      <result>
        <apiCreatable>true</apiCreatable>
        <apiDeletable>true</apiDeletable>
        <apiReadable>true</apiReadable>
        <apiUpdatable>true</apiUpdatable>
        <valueTypeFields>
          <isForeignKey>false</isForeignKey>
          <isNameField>true</isNameField>
          <minOccurs>1</minOccurs>
          <name>fullName</name>
          <soapType>string</soapType>
          <valueRequired>true</valueRequired>
        </valueTypeFields>
        <valueTypeFields>
          <isForeignKey>false</isForeignKey>
          <isNameField>false</isNameField>
          <minOccurs>0</minOccurs>
          <name>apiVersion</name>
          <soapType>double</soapType>
          <valueRequired>false</valueRequired>
        </valueTypeFields>
        <valueTypeFields>
          <isForeignKey>false</isForeignKey>
          <isNameField>false</isNameField>
          <minOccurs>0</minOccurs>
          <name>status</name>
          <soapType>ApexCodeUnitStatus</soapType>
          <valueRequired>false</valueRequired>
          <picklistValues>
            <active>true</active>
            <defaultValue>true</defaultValue>
            <value>Active</value>
          </picklistValues>
          <picklistValues>
            <active>true</active>
            <defaultValue>false</defaultValue>
            <value>Deleted</value>
          </picklistValues>
        </valueTypeFields>
      </result>
    </describeValueTypeResponse>
  </soapenv:Body>
</soapenv:Envelope>"#,
        ))
        .mount(&server)
        .await;

    let md = client_against(&server);
    let result = md
        .describe_value_type("{http://soap.sforce.com/2006/04/metadata}ApexClass")
        .await
        .unwrap();

    assert!(result.api_creatable);
    assert!(result.api_deletable);
    assert!(result.api_readable);
    assert!(result.api_updatable);
    assert_eq!(result.value_type_fields.len(), 3);

    let full_name = &result.value_type_fields[0];
    assert_eq!(full_name.name, Some("fullName".into()));
    assert_eq!(full_name.soap_type, Some("string".into()));
    assert_eq!(full_name.min_occurs, 1);
    assert!(full_name.is_name_field);
    assert!(full_name.value_required);
    assert!(full_name.picklist_values.is_empty());

    let status = &result.value_type_fields[2];
    assert_eq!(status.name, Some("status".into()));
    assert_eq!(status.picklist_values.len(), 2);
    assert_eq!(status.picklist_values[0].value, Some("Active".into()));
    assert!(status.picklist_values[0].default_value);
    assert_eq!(status.picklist_values[1].value, Some("Deleted".into()));
}