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
+
11
+use async_trait::async_trait;
12
+use reqwest::Method;
13
+use serde::{Deserialize, Serialize};
14
+use smbcloud_ascapi_core::jsonapi::{Resource, ResourceId};
15
+use smbcloud_ascapi_core::Client;
16
+use smbcloud_ascapi_core::Result;
17
+
18
+pub const RESOURCE_TYPE: &str = "betaGroups";
19
+
20
+/// Attributes of a TestFlight beta group.
21
+#[derive(Debug, Clone, Serialize, Deserialize)]
22
+#[serde(rename_all = "camelCase")]
23
+pub 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.
41
+pub 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)]
49
+struct 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]
60
+pub 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]
79
+impl 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)]
118
+mod 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
+}