development
rs 208 lines 7.95 KB
Raw
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 }