| 1 | //! `BetaAppReviewSubmission` — submit a build to Apple's Beta App Review |
| 2 | //! for external TestFlight distribution approval. |
| 3 | //! |
| 4 | //! Before external testers can install a build, Apple must approve it via |
| 5 | //! Beta App Review. This module lets a caller: |
| 6 | //! |
| 7 | //! 1. Submit a build (`POST /v1/betaAppReviewSubmissions`) — creates a |
| 8 | //! submission and immediately sends the build to Apple for review. |
| 9 | //! 2. Query the current submission state (`GET /v1/betaAppReviewSubmissions?filter[build]={id}`). |
| 10 | //! |
| 11 | //! The submission itself only ever has the relationship to a `build`; |
| 12 | //! contact / demo details come from [`crate::beta_app_review_detail`]. |
| 13 | |
| 14 | use async_trait::async_trait; |
| 15 | use reqwest::Method; |
| 16 | use serde::{Deserialize, Serialize}; |
| 17 | use smbcloud_ascapi_core::jsonapi::{Document, ListDocument, Resource, ResourceId, ToOne}; |
| 18 | use smbcloud_ascapi_core::Client; |
| 19 | use smbcloud_ascapi_core::Result; |
| 20 | |
| 21 | pub const RESOURCE_TYPE: &str = "betaAppReviewSubmissions"; |
| 22 | |
| 23 | /// The review state Apple reports for a beta submission. |
| 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 25 | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |
| 26 | pub enum BetaReviewState { |
| 27 | /// The submission is waiting for App Review to pick it up. |
| 28 | WaitingForReview, |
| 29 | /// Apple is actively reviewing the build. |
| 30 | InReview, |
| 31 | /// Beta App Review rejected the build. |
| 32 | Rejected, |
| 33 | /// Beta App Review approved the build; external testing can begin. |
| 34 | Approved, |
| 35 | } |
| 36 | |
| 37 | /// Attributes of a beta app review submission. |
| 38 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 39 | #[serde(rename_all = "camelCase")] |
| 40 | pub struct BetaAppReviewSubmissionAttributes { |
| 41 | /// Current state of the review. |
| 42 | pub beta_review_state: Option<BetaReviewState>, |
| 43 | /// Submission timestamp (ISO 8601). |
| 44 | pub submitted_date: Option<String>, |
| 45 | } |
| 46 | |
| 47 | /// A beta app review submission resource. |
| 48 | pub type BetaAppReviewSubmission = Resource<BetaAppReviewSubmissionAttributes>; |
| 49 | |
| 50 | // ── internal create helper ──────────────────────────────────────────────────── |
| 51 | |
| 52 | /// A beta app review submission carries only a `build` relationship. Apple's |
| 53 | /// endpoint rejects a request whose `data.attributes` is present but null |
| 54 | /// (422 `ENTITY_UNPROCESSABLE`), so this body omits the field entirely rather |
| 55 | /// than reusing [`smbcloud_ascapi_core::jsonapi::CreateBody`] with `()` |
| 56 | /// attributes. |
| 57 | #[derive(Debug, Clone, Serialize)] |
| 58 | struct CreateBody { |
| 59 | data: CreateData, |
| 60 | } |
| 61 | |
| 62 | #[derive(Debug, Clone, Serialize)] |
| 63 | struct CreateData { |
| 64 | #[serde(rename = "type")] |
| 65 | resource_type: &'static str, |
| 66 | relationships: CreateRelationships, |
| 67 | } |
| 68 | |
| 69 | #[derive(Debug, Clone, Serialize)] |
| 70 | struct CreateRelationships { |
| 71 | build: ToOne, |
| 72 | } |
| 73 | |
| 74 | // ── trait ───────────────────────────────────────────────────────────────────── |
| 75 | |
| 76 | /// Beta app review submission operations on App Store Connect. |
| 77 | /// |
| 78 | /// An extension trait rather than inherent methods, because `Client` |
| 79 | /// lives in the core crate and Rust only allows inherent impls in the |
| 80 | /// crate that defines the type. Import it, or the crate's `prelude`, |
| 81 | /// to call these on a `Client`. |
| 82 | #[async_trait] |
| 83 | pub trait BetaAppReviewSubmissionsApi { |
| 84 | /// `POST /v1/betaAppReviewSubmissions` — submit a build to Beta App Review. |
| 85 | /// |
| 86 | /// **CLI-only** — this is a destructive, irreversible operation that |
| 87 | /// triggers Apple's review queue. It must not be called from an MCP tool |
| 88 | /// without explicit user confirmation, and it is intentionally not |
| 89 | /// exposed as an MCP tool here. |
| 90 | /// |
| 91 | /// `build_id` is the opaque ASC id of the build (not the build number). |
| 92 | /// On success the returned submission's `beta_review_state` is typically |
| 93 | /// `WAITING_FOR_REVIEW`. |
| 94 | async fn submit_build_for_beta_review(&self, build_id: &str) |
| 95 | -> Result<BetaAppReviewSubmission>; |
| 96 | |
| 97 | /// `GET /v1/betaAppReviewSubmissions?filter[build]={build_id}` — find |
| 98 | /// the current submission record for a build. |
| 99 | /// |
| 100 | /// Returns at most one item in practice (one build → one submission), but |
| 101 | /// the API is a list endpoint so we return `Vec`. |
| 102 | async fn list_beta_app_review_submissions( |
| 103 | &self, |
| 104 | build_id: &str, |
| 105 | ) -> Result<Vec<BetaAppReviewSubmission>>; |
| 106 | } |
| 107 | |
| 108 | #[async_trait] |
| 109 | impl BetaAppReviewSubmissionsApi for Client { |
| 110 | async fn submit_build_for_beta_review( |
| 111 | &self, |
| 112 | build_id: &str, |
| 113 | ) -> Result<BetaAppReviewSubmission> { |
| 114 | let body = CreateBody { |
| 115 | data: CreateData { |
| 116 | resource_type: RESOURCE_TYPE, |
| 117 | relationships: CreateRelationships { |
| 118 | build: ToOne { |
| 119 | data: ResourceId { |
| 120 | resource_type: crate::build::RESOURCE_TYPE, |
| 121 | id: build_id.to_string(), |
| 122 | }, |
| 123 | }, |
| 124 | }, |
| 125 | }, |
| 126 | }; |
| 127 | let doc: Document<BetaAppReviewSubmissionAttributes> = self |
| 128 | .request( |
| 129 | Method::POST, |
| 130 | "/v1/betaAppReviewSubmissions", |
| 131 | &[], |
| 132 | Some(&body), |
| 133 | ) |
| 134 | .await?; |
| 135 | Ok(doc.data) |
| 136 | } |
| 137 | |
| 138 | async fn list_beta_app_review_submissions( |
| 139 | &self, |
| 140 | build_id: &str, |
| 141 | ) -> Result<Vec<BetaAppReviewSubmission>> { |
| 142 | let path = "/v1/betaAppReviewSubmissions"; |
| 143 | let query = &[("filter[build]", build_id)]; |
| 144 | let doc: ListDocument<BetaAppReviewSubmissionAttributes> = |
| 145 | self.request(Method::GET, path, query, None::<&()>).await?; |
| 146 | Ok(doc.data) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | #[cfg(test)] |
| 151 | mod tests { |
| 152 | use super::*; |
| 153 | |
| 154 | #[test] |
| 155 | fn deserializes_a_submission_list() { |
| 156 | let body = serde_json::json!({ |
| 157 | "data": [ |
| 158 | { |
| 159 | "id": "SUB1", |
| 160 | "type": "betaAppReviewSubmissions", |
| 161 | "attributes": { |
| 162 | "betaReviewState": "WAITING_FOR_REVIEW", |
| 163 | "submittedDate": "2025-01-01T00:00:00.000+0000" |
| 164 | } |
| 165 | } |
| 166 | ] |
| 167 | }); |
| 168 | |
| 169 | let doc: ListDocument<BetaAppReviewSubmissionAttributes> = |
| 170 | serde_json::from_value(body).expect("submission list must deserialize"); |
| 171 | assert_eq!(doc.data.len(), 1); |
| 172 | let sub = &doc.data[0]; |
| 173 | assert_eq!(sub.id, "SUB1"); |
| 174 | assert_eq!( |
| 175 | sub.attributes.beta_review_state, |
| 176 | Some(BetaReviewState::WaitingForReview) |
| 177 | ); |
| 178 | assert_eq!( |
| 179 | sub.attributes.submitted_date.as_deref(), |
| 180 | Some("2025-01-01T00:00:00.000+0000") |
| 181 | ); |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn deserializes_approved_state() { |
| 186 | let body = serde_json::json!({ |
| 187 | "data": { |
| 188 | "id": "SUB2", |
| 189 | "type": "betaAppReviewSubmissions", |
| 190 | "attributes": { |
| 191 | "betaReviewState": "APPROVED", |
| 192 | "submittedDate": "2025-02-01T10:00:00.000+0000" |
| 193 | } |
| 194 | } |
| 195 | }); |
| 196 | |
| 197 | let doc: Document<BetaAppReviewSubmissionAttributes> = |
| 198 | serde_json::from_value(body).expect("single submission must deserialize"); |
| 199 | assert_eq!( |
| 200 | doc.data.attributes.beta_review_state, |
| 201 | Some(BetaReviewState::Approved) |
| 202 | ); |
| 203 | } |
| 204 | |
| 205 | #[test] |
| 206 | fn filter_key_is_exact() { |
| 207 | // ASC is case-sensitive about filter keys; confirm the exact string. |
| 208 | let query: Vec<(&str, &str)> = vec![("filter[build]", "BUILD_ID")]; |
| 209 | assert_eq!(query[0].0, "filter[build]"); |
| 210 | } |
| 211 | |
| 212 | #[test] |
| 213 | fn create_body_omits_attributes() { |
| 214 | // Apple's endpoint 422s on a null `data.attributes`; the body must omit |
| 215 | // the field entirely and carry only the `build` relationship. |
| 216 | let body = CreateBody { |
| 217 | data: CreateData { |
| 218 | resource_type: RESOURCE_TYPE, |
| 219 | relationships: CreateRelationships { |
| 220 | build: ToOne { |
| 221 | data: ResourceId { |
| 222 | resource_type: crate::build::RESOURCE_TYPE, |
| 223 | id: "BUILD_ID".to_string(), |
| 224 | }, |
| 225 | }, |
| 226 | }, |
| 227 | }, |
| 228 | }; |
| 229 | let v = serde_json::to_value(&body).unwrap(); |
| 230 | assert!( |
| 231 | v["data"].get("attributes").is_none(), |
| 232 | "attributes must be omitted, not null: {v}" |
| 233 | ); |
| 234 | assert_eq!(v["data"]["type"], RESOURCE_TYPE); |
| 235 | assert_eq!( |
| 236 | v["data"]["relationships"]["build"]["data"]["id"], |
| 237 | "BUILD_ID" |
| 238 | ); |
| 239 | } |
| 240 | } |