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