cnctd_appstore 0.1.0

App Store Connect API client for TestFlight, builds, and submissions
Documentation
use anyhow::{anyhow, Result};
use serde::Deserialize;
use serde_json::json;

use crate::client::{AppStoreConnectClient, AscResponse, BASE_URL};

/// Beta tester data returned from the API
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BetaTesterData {
    id: String,
    #[allow(dead_code)]
    #[serde(rename = "type")]
    data_type: String,
}

impl AppStoreConnectClient {
    /// Add a beta tester to TestFlight and assign to a beta group.
    ///
    /// If `beta_group_id` is None, uses the default from ASC_BETA_GROUP_ID.
    /// Returns the tester ID on success, or "already_exists" if the tester
    /// is already registered (409 Conflict).
    pub async fn add_beta_tester(
        &self,
        email: &str,
        first_name: &str,
        last_name: &str,
        beta_group_id: Option<&str>,
    ) -> Result<String> {
        let token = self.generate_token()?;

        let group_id = beta_group_id
            .map(|s| s.to_string())
            .or_else(|| self.default_beta_group_id.clone())
            .ok_or_else(|| anyhow!("No beta group ID provided and ASC_BETA_GROUP_ID not set"))?;

        let body = json!({
            "data": {
                "type": "betaTesters",
                "attributes": {
                    "email": email,
                    "firstName": first_name,
                    "lastName": last_name
                },
                "relationships": {
                    "betaGroups": {
                        "data": [
                            {
                                "type": "betaGroups",
                                "id": group_id
                            }
                        ]
                    }
                }
            }
        });

        let response = self.client
            .post(format!("{}/betaTesters", BASE_URL))
            .header("Authorization", format!("Bearer {}", token))
            .header("Content-Type", "application/json")
            .json(&body)
            .send()
            .await
            .map_err(|e| anyhow!("Failed to call App Store Connect API: {}", e))?;

        let status = response.status();

        if status.is_success() {
            let result: AscResponse<BetaTesterData> = response.json().await
                .map_err(|e| anyhow!("Failed to parse ASC response: {}", e))?;
            log::info!("Added beta tester {} (id: {})", email, result.data.id);
            Ok(result.data.id)
        } else if status.as_u16() == 409 {
            log::info!("Beta tester {} already exists, skipping", email);
            Ok(String::from("already_exists"))
        } else {
            let error_body = response.text().await.unwrap_or_default();
            Err(anyhow!("App Store Connect API error ({}): {}", status, Self::parse_error(status, &error_body)))
        }
    }

    /// Remove a beta tester from a specific beta group (does not delete the tester)
    pub async fn remove_tester_from_group(
        &self,
        tester_id: &str,
        beta_group_id: Option<&str>,
    ) -> Result<()> {
        let token = self.generate_token()?;

        let group_id = beta_group_id
            .map(|s| s.to_string())
            .or_else(|| self.default_beta_group_id.clone())
            .ok_or_else(|| anyhow!("No beta group ID provided and ASC_BETA_GROUP_ID not set"))?;

        let body = json!({
            "data": [
                {
                    "type": "betaTesters",
                    "id": tester_id
                }
            ]
        });

        let response = self.client
            .delete(format!("{}/betaGroups/{}/relationships/betaTesters", BASE_URL, group_id))
            .header("Authorization", format!("Bearer {}", token))
            .header("Content-Type", "application/json")
            .json(&body)
            .send()
            .await
            .map_err(|e| anyhow!("Failed to call App Store Connect API: {}", e))?;

        if response.status().is_success() || response.status().as_u16() == 204 {
            log::info!("Removed tester {} from group {}", tester_id, group_id);
            Ok(())
        } else {
            let error_body = response.text().await.unwrap_or_default();
            Err(anyhow!("Failed to remove tester from group: {}", error_body))
        }
    }
}