development
rs 240 lines 8.55 KB
Raw
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 }