| 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 | } |