Skip to main content

smbcloud_ascapi_aso/
beta_group.rs

1//! `BetaGroup` — a TestFlight distribution group that can receive builds.
2//!
3//! A beta group is either an *internal* tester group (only team members with
4//! an Apple Developer account role) or an *external* group (anyone with an
5//! email invite). Adding a build to an external group triggers the optional
6//! "Beta App Review" gate; internal groups skip it.
7//!
8//! The writable operation here — adding a build to a group — is deliberate
9//! and potentially public-facing, so it stays CLI-only in the front end.
10
11use async_trait::async_trait;
12use reqwest::Method;
13use serde::{Deserialize, Serialize};
14use smbcloud_ascapi_core::jsonapi::{Resource, ResourceId};
15use smbcloud_ascapi_core::Client;
16use smbcloud_ascapi_core::Result;
17
18pub const RESOURCE_TYPE: &str = "betaGroups";
19
20/// Attributes of a TestFlight beta group.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct BetaGroupAttributes {
24    /// Display name of the group, e.g. `"Internal Testers"`.
25    pub name: Option<String>,
26    /// Whether this is an internal-only group (team members only).
27    pub is_internal_group: Option<bool>,
28    /// Whether new builds are automatically distributed to this group.
29    pub has_access_to_all_builds: Option<bool>,
30    /// ISO 8601 timestamp of when the group was created.
31    pub created_date: Option<String>,
32    /// Whether a public invite link is enabled for this group.
33    pub public_link_enabled: Option<bool>,
34    /// The public invite link URL, if enabled.
35    pub public_link: Option<String>,
36    /// Current number of testers who have accepted through the public link.
37    pub public_link_limit: Option<i64>,
38}
39
40/// A TestFlight beta group resource.
41pub type BetaGroup = Resource<BetaGroupAttributes>;
42
43/// Body used to add (or replace) the builds relationship on a beta group.
44///
45/// `POST /v1/betaGroups/{id}/relationships/builds` appends builds;
46/// `PATCH /v1/betaGroups/{id}/relationships/builds` replaces the list.
47/// We only need the append form here.
48#[derive(Debug, Clone, Serialize)]
49struct AddBuildsBody {
50    data: Vec<ResourceId>,
51}
52
53/// Beta group operations on App Store Connect.
54///
55/// An extension trait rather than inherent methods, because `Client`
56/// lives in the core crate and Rust only allows inherent impls in the
57/// crate that defines the type. Import it, or the crate's `prelude`,
58/// to call these on a `Client`.
59#[async_trait]
60pub trait BetaGroupsApi {
61    /// `GET /v1/betaGroups?filter[app]={app_id}` — all beta groups for
62    /// an app, sorted alphabetically by `name` client-side so internal
63    /// and external groups are interleaved predictably.
64    async fn list_beta_groups(&self, app_id: &str) -> Result<Vec<BetaGroup>>;
65
66    /// `GET /v1/betaGroups/{id}` — a single group by its ASC id.
67    async fn get_beta_group(&self, id: &str) -> Result<BetaGroup>;
68
69    /// `POST /v1/betaGroups/{group_id}/relationships/builds` — add one or
70    /// more builds to a beta group by their ASC build ids.
71    ///
72    /// This is an *append* operation: existing builds in the group are
73    /// unaffected. Adding a build to an external group may trigger Beta
74    /// App Review if the app has not received approval for that group yet.
75    async fn add_builds_to_beta_group(&self, group_id: &str, build_ids: &[&str]) -> Result<()>;
76}
77
78#[async_trait]
79impl BetaGroupsApi for Client {
80    async fn list_beta_groups(&self, app_id: &str) -> Result<Vec<BetaGroup>> {
81        let mut groups = self
82            .list_all::<BetaGroupAttributes>(
83                "/v1/betaGroups",
84                &[("filter[app]", app_id), ("limit", "200")],
85            )
86            .await?;
87        groups.sort_by(|a, b| a.attributes.name.cmp(&b.attributes.name));
88        Ok(groups)
89    }
90
91    async fn get_beta_group(&self, id: &str) -> Result<BetaGroup> {
92        use smbcloud_ascapi_core::jsonapi::Document;
93        let path = format!("/v1/betaGroups/{id}");
94        let doc: Document<BetaGroupAttributes> =
95            self.request(Method::GET, &path, &[], None::<&()>).await?;
96        Ok(doc.data)
97    }
98
99    async fn add_builds_to_beta_group(&self, group_id: &str, build_ids: &[&str]) -> Result<()> {
100        let path = format!("/v1/betaGroups/{group_id}/relationships/builds");
101        let body = AddBuildsBody {
102            data: build_ids
103                .iter()
104                .map(|id| ResourceId {
105                    resource_type: crate::build::RESOURCE_TYPE,
106                    id: id.to_string(),
107                })
108                .collect(),
109        };
110        // A successful POST to a relationships endpoint returns 204 No Content.
111        // `request_no_content` (if available) or discard the empty body.
112        self.request_no_content(Method::POST, &path, &[], Some(&body))
113            .await
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use smbcloud_ascapi_core::jsonapi::ListDocument;
121
122    #[test]
123    fn deserializes_a_beta_groups_list() {
124        let body = serde_json::json!({
125            "data": [
126                {
127                    "id": "BG1",
128                    "type": "betaGroups",
129                    "attributes": {
130                        "name": "Internal Testers",
131                        "isInternalGroup": true,
132                        "hasAccessToAllBuilds": true,
133                        "createdDate": "2024-01-01T00:00:00.000+0000",
134                        "publicLinkEnabled": false,
135                        "publicLink": null,
136                        "publicLinkLimit": null
137                    }
138                },
139                {
140                    "id": "BG2",
141                    "type": "betaGroups",
142                    "attributes": {
143                        "name": "External Beta",
144                        "isInternalGroup": false,
145                        "hasAccessToAllBuilds": false,
146                        "createdDate": "2024-02-01T00:00:00.000+0000",
147                        "publicLinkEnabled": true,
148                        "publicLink": "https://testflight.apple.com/join/abc123",
149                        "publicLinkLimit": 1000
150                    }
151                }
152            ]
153        });
154
155        let doc: ListDocument<BetaGroupAttributes> =
156            serde_json::from_value(body).expect("beta groups list must deserialize");
157        assert_eq!(doc.data.len(), 2);
158
159        let internal = &doc.data[0];
160        assert_eq!(internal.id, "BG1");
161        assert_eq!(
162            internal.attributes.name.as_deref(),
163            Some("Internal Testers")
164        );
165        assert_eq!(internal.attributes.is_internal_group, Some(true));
166        assert_eq!(internal.attributes.has_access_to_all_builds, Some(true));
167        assert_eq!(internal.attributes.public_link_enabled, Some(false));
168
169        let external = &doc.data[1];
170        assert_eq!(external.id, "BG2");
171        assert_eq!(external.attributes.name.as_deref(), Some("External Beta"));
172        assert_eq!(external.attributes.is_internal_group, Some(false));
173        assert_eq!(
174            external.attributes.public_link.as_deref(),
175            Some("https://testflight.apple.com/join/abc123")
176        );
177        assert_eq!(external.attributes.public_link_limit, Some(1000));
178    }
179
180    #[test]
181    fn add_builds_body_serializes_correctly() {
182        let body = AddBuildsBody {
183            data: vec![
184                ResourceId {
185                    resource_type: "builds",
186                    id: "BUILD1".into(),
187                },
188                ResourceId {
189                    resource_type: "builds",
190                    id: "BUILD2".into(),
191                },
192            ],
193        };
194        let v = serde_json::to_value(&body).unwrap();
195        let data = v["data"].as_array().unwrap();
196        assert_eq!(data.len(), 2);
197        assert_eq!(data[0]["type"], "builds");
198        assert_eq!(data[0]["id"], "BUILD1");
199        assert_eq!(data[1]["id"], "BUILD2");
200    }
201
202    #[test]
203    fn empty_build_ids_produces_empty_data_array() {
204        let body = AddBuildsBody { data: vec![] };
205        let v = serde_json::to_value(&body).unwrap();
206        assert!(v["data"].as_array().unwrap().is_empty());
207    }
208}