main
rs 181 lines 7.38 KB
Raw
1 //! `BetaAppReviewDetail` — contact and demo info that Apple's Beta App Review
2 //! team uses when reviewing an app for external TestFlight distribution.
3 //!
4 //! Every app has exactly one `BetaAppReviewDetail`. It does not need to be
5 //! created — it exists by default. The caller `GET`s it to see the current
6 //! state, and `PATCH`es it to update contact information before submitting
7 //! a build for Beta App Review.
8
9 use async_trait::async_trait;
10 use reqwest::Method;
11 use serde::{Deserialize, Serialize};
12 use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData};
13 use smbcloud_ascapi_core::Client;
14 use smbcloud_ascapi_core::Result;
15
16 pub const RESOURCE_TYPE: &str = "betaAppReviewDetails";
17
18 /// Contact and demo-account information used by the Beta App Review team.
19 #[derive(Debug, Clone, Serialize, Deserialize)]
20 #[serde(rename_all = "camelCase")]
21 pub struct BetaAppReviewDetailAttributes {
22 /// Contact email for Beta App Review questions.
23 pub contact_email: Option<String>,
24 /// Contact first name.
25 pub contact_first_name: Option<String>,
26 /// Contact last name.
27 pub contact_last_name: Option<String>,
28 /// Contact phone number (international format recommended).
29 pub contact_phone: Option<String>,
30 /// Username for a demo account, if the app requires login to review.
31 pub demo_account_name: Option<String>,
32 /// Password for the demo account.
33 pub demo_account_password: Option<String>,
34 /// Whether a demo account is required to review the app.
35 pub demo_account_required: Option<bool>,
36 /// Free-form notes for the reviewer (max 4000 chars).
37 pub notes: Option<String>,
38 }
39
40 /// A beta app review detail resource.
41 pub type BetaAppReviewDetail = Resource<BetaAppReviewDetailAttributes>;
42
43 // ── update helper ─────────────────────────────────────────────────────────────
44
45 /// Partial update attributes — all fields are optional so a caller can
46 /// update only what has changed.
47 #[derive(Debug, Clone, Default, Serialize)]
48 #[serde(rename_all = "camelCase")]
49 pub struct BetaAppReviewDetailUpdate {
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub contact_email: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub contact_first_name: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub contact_last_name: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub contact_phone: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub demo_account_name: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub demo_account_password: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub demo_account_required: Option<bool>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub notes: Option<String>,
66 }
67
68 // ── trait ─────────────────────────────────────────────────────────────────────
69
70 /// Beta app review detail operations on App Store Connect.
71 ///
72 /// An extension trait rather than inherent methods, because `Client`
73 /// lives in the core crate and Rust only allows inherent impls in the
74 /// crate that defines the type. Import it, or the crate's `prelude`,
75 /// to call these on a `Client`.
76 #[async_trait]
77 pub trait BetaAppReviewDetailApi {
78 /// `GET /v1/apps/{app_id}/betaAppReviewDetail` — fetch the review detail
79 /// for an app via the relationship sub-resource. This is the canonical
80 /// way to get the detail without knowing its opaque id up front.
81 async fn get_beta_app_review_detail(&self, app_id: &str) -> Result<BetaAppReviewDetail>;
82
83 /// `PATCH /v1/betaAppReviewDetails/{id}` — update contact / demo-account
84 /// fields. The id comes from a prior `get_beta_app_review_detail` call.
85 ///
86 /// Only fields set (non-`None`) in `update` are sent; other fields are
87 /// left unchanged on Apple's side.
88 async fn update_beta_app_review_detail(
89 &self,
90 id: &str,
91 update: BetaAppReviewDetailUpdate,
92 ) -> Result<BetaAppReviewDetail>;
93 }
94
95 #[async_trait]
96 impl BetaAppReviewDetailApi for Client {
97 async fn get_beta_app_review_detail(&self, app_id: &str) -> Result<BetaAppReviewDetail> {
98 let path = format!("/v1/apps/{app_id}/betaAppReviewDetail");
99 let doc: Document<BetaAppReviewDetailAttributes> =
100 self.request(Method::GET, &path, &[], None::<&()>).await?;
101 Ok(doc.data)
102 }
103
104 async fn update_beta_app_review_detail(
105 &self,
106 id: &str,
107 update: BetaAppReviewDetailUpdate,
108 ) -> Result<BetaAppReviewDetail> {
109 let path = format!("/v1/betaAppReviewDetails/{id}");
110 let body = UpdateBody {
111 data: UpdateData {
112 resource_type: RESOURCE_TYPE,
113 id: id.to_string(),
114 attributes: update,
115 },
116 };
117 let doc: Document<BetaAppReviewDetailAttributes> =
118 self.request(Method::PATCH, &path, &[], Some(&body)).await?;
119 Ok(doc.data)
120 }
121 }
122
123 #[cfg(test)]
124 mod tests {
125 use super::*;
126
127 #[test]
128 fn deserializes_a_beta_app_review_detail() {
129 let body = serde_json::json!({
130 "data": {
131 "id": "DETAIL1",
132 "type": "betaAppReviewDetails",
133 "attributes": {
134 "contactEmail": "dev@example.com",
135 "contactFirstName": "Jane",
136 "contactLastName": "Developer",
137 "contactPhone": "+1-555-0100",
138 "demoAccountName": "demo@example.com",
139 "demoAccountPassword": "hunter2",
140 "demoAccountRequired": true,
141 "notes": "Tap the red button to reach the main feature."
142 }
143 }
144 });
145
146 let doc: Document<BetaAppReviewDetailAttributes> =
147 serde_json::from_value(body).expect("review detail must deserialize");
148 let attrs = &doc.data.attributes;
149 assert_eq!(doc.data.id, "DETAIL1");
150 assert_eq!(attrs.contact_email.as_deref(), Some("dev@example.com"));
151 assert_eq!(attrs.contact_first_name.as_deref(), Some("Jane"));
152 assert_eq!(attrs.demo_account_required, Some(true));
153 assert_eq!(
154 attrs.notes.as_deref(),
155 Some("Tap the red button to reach the main feature.")
156 );
157 }
158
159 #[test]
160 fn update_body_skips_none_fields() {
161 let update = BetaAppReviewDetailUpdate {
162 contact_email: Some("new@example.com".into()),
163 notes: Some("Updated notes.".into()),
164 ..Default::default()
165 };
166 let body = UpdateBody {
167 data: UpdateData {
168 resource_type: RESOURCE_TYPE,
169 id: "DETAIL1".into(),
170 attributes: update,
171 },
172 };
173 let v = serde_json::to_value(&body).unwrap();
174 let attrs = &v["data"]["attributes"];
175 assert_eq!(attrs["contactEmail"], "new@example.com");
176 assert_eq!(attrs["notes"], "Updated notes.");
177 // Fields not set must be absent (not null), so the PATCH is truly partial.
178 assert!(attrs.get("contactFirstName").is_none() || attrs["contactFirstName"].is_null());
179 assert!(attrs.get("demoAccountRequired").is_none());
180 }
181 }