| 1 | //! `BetaBuildLocalization` — per-locale "What to Test" notes on a TestFlight build. |
| 2 | //! |
| 3 | //! Each build can carry one `BetaBuildLocalization` per locale (e.g. |
| 4 | //! `"en-US"`, `"ja"`). These notes appear to testers in the TestFlight app |
| 5 | //! beneath the build entry. Creating or updating them is optional; if absent |
| 6 | //! the build simply shows no notes. |
| 7 | |
| 8 | use async_trait::async_trait; |
| 9 | use reqwest::Method; |
| 10 | use serde::{Deserialize, Serialize}; |
| 11 | use smbcloud_ascapi_core::jsonapi::{ |
| 12 | CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData, |
| 13 | }; |
| 14 | use smbcloud_ascapi_core::Client; |
| 15 | use smbcloud_ascapi_core::Result; |
| 16 | |
| 17 | pub const RESOURCE_TYPE: &str = "betaBuildLocalizations"; |
| 18 | |
| 19 | /// Attributes of a per-locale beta build note. |
| 20 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 21 | #[serde(rename_all = "camelCase")] |
| 22 | pub struct BetaBuildLocalizationAttributes { |
| 23 | /// BCP 47 locale tag, e.g. `"en-US"` or `"ja"`. |
| 24 | pub locale: Option<String>, |
| 25 | /// Free-form "What to Test" note shown to testers (max 4000 chars). |
| 26 | pub whats_new: Option<String>, |
| 27 | } |
| 28 | |
| 29 | /// A beta build localization resource. |
| 30 | pub type BetaBuildLocalization = Resource<BetaBuildLocalizationAttributes>; |
| 31 | |
| 32 | // ── internal create/update helpers ─────────────────────────────────────────── |
| 33 | |
| 34 | #[derive(Debug, Clone, Serialize)] |
| 35 | #[serde(rename_all = "camelCase")] |
| 36 | struct CreateAttributes { |
| 37 | locale: String, |
| 38 | whats_new: Option<String>, |
| 39 | } |
| 40 | |
| 41 | #[derive(Debug, Clone, Serialize)] |
| 42 | struct CreateRelationships { |
| 43 | build: ToOne, |
| 44 | } |
| 45 | |
| 46 | #[derive(Debug, Clone, Serialize)] |
| 47 | #[serde(rename_all = "camelCase")] |
| 48 | struct UpdateAttributes { |
| 49 | whats_new: String, |
| 50 | } |
| 51 | |
| 52 | // ── trait ───────────────────────────────────────────────────────────────────── |
| 53 | |
| 54 | /// Beta build localization operations on App Store Connect. |
| 55 | /// |
| 56 | /// An extension trait rather than inherent methods, because `Client` |
| 57 | /// lives in the core crate and Rust only allows inherent impls in the |
| 58 | /// crate that defines the type. Import it, or the crate's `prelude`, |
| 59 | /// to call these on a `Client`. |
| 60 | #[async_trait] |
| 61 | pub trait BetaBuildLocalizationsApi { |
| 62 | /// `GET /v1/betaBuildLocalizations?filter[build]={build_id}` — all |
| 63 | /// locale notes for a build. |
| 64 | async fn list_beta_build_localizations( |
| 65 | &self, |
| 66 | build_id: &str, |
| 67 | ) -> Result<Vec<BetaBuildLocalization>>; |
| 68 | |
| 69 | /// `GET /v1/betaBuildLocalizations/{id}` — a single note by its ASC id. |
| 70 | async fn get_beta_build_localization(&self, id: &str) -> Result<BetaBuildLocalization>; |
| 71 | |
| 72 | /// `POST /v1/betaBuildLocalizations` — create a locale note for a build. |
| 73 | /// |
| 74 | /// `locale` is a BCP 47 tag (e.g. `"en-US"`). `whats_new` is optional |
| 75 | /// at creation; App Store Connect accepts an empty record. |
| 76 | async fn create_beta_build_localization( |
| 77 | &self, |
| 78 | build_id: &str, |
| 79 | locale: &str, |
| 80 | whats_new: Option<&str>, |
| 81 | ) -> Result<BetaBuildLocalization>; |
| 82 | |
| 83 | /// `PATCH /v1/betaBuildLocalizations/{id}` — update the "What to Test" |
| 84 | /// text for an existing localization. |
| 85 | async fn update_beta_build_localization( |
| 86 | &self, |
| 87 | id: &str, |
| 88 | whats_new: &str, |
| 89 | ) -> Result<BetaBuildLocalization>; |
| 90 | } |
| 91 | |
| 92 | #[async_trait] |
| 93 | impl BetaBuildLocalizationsApi for Client { |
| 94 | async fn list_beta_build_localizations( |
| 95 | &self, |
| 96 | build_id: &str, |
| 97 | ) -> Result<Vec<BetaBuildLocalization>> { |
| 98 | self.list_all::<BetaBuildLocalizationAttributes>( |
| 99 | "/v1/betaBuildLocalizations", |
| 100 | &[("filter[build]", build_id), ("limit", "200")], |
| 101 | ) |
| 102 | .await |
| 103 | } |
| 104 | |
| 105 | async fn get_beta_build_localization(&self, id: &str) -> Result<BetaBuildLocalization> { |
| 106 | let path = format!("/v1/betaBuildLocalizations/{id}"); |
| 107 | let doc: Document<BetaBuildLocalizationAttributes> = |
| 108 | self.request(Method::GET, &path, &[], None::<&()>).await?; |
| 109 | Ok(doc.data) |
| 110 | } |
| 111 | |
| 112 | async fn create_beta_build_localization( |
| 113 | &self, |
| 114 | build_id: &str, |
| 115 | locale: &str, |
| 116 | whats_new: Option<&str>, |
| 117 | ) -> Result<BetaBuildLocalization> { |
| 118 | let body = CreateBody { |
| 119 | data: CreateData { |
| 120 | resource_type: RESOURCE_TYPE, |
| 121 | attributes: CreateAttributes { |
| 122 | locale: locale.to_string(), |
| 123 | whats_new: whats_new.map(str::to_string), |
| 124 | }, |
| 125 | relationships: Some(CreateRelationships { |
| 126 | build: ToOne { |
| 127 | data: ResourceId { |
| 128 | resource_type: crate::build::RESOURCE_TYPE, |
| 129 | id: build_id.to_string(), |
| 130 | }, |
| 131 | }, |
| 132 | }), |
| 133 | }, |
| 134 | }; |
| 135 | let doc: Document<BetaBuildLocalizationAttributes> = self |
| 136 | .request(Method::POST, "/v1/betaBuildLocalizations", &[], Some(&body)) |
| 137 | .await?; |
| 138 | Ok(doc.data) |
| 139 | } |
| 140 | |
| 141 | async fn update_beta_build_localization( |
| 142 | &self, |
| 143 | id: &str, |
| 144 | whats_new: &str, |
| 145 | ) -> Result<BetaBuildLocalization> { |
| 146 | let path = format!("/v1/betaBuildLocalizations/{id}"); |
| 147 | let body = UpdateBody { |
| 148 | data: UpdateData { |
| 149 | resource_type: RESOURCE_TYPE, |
| 150 | id: id.to_string(), |
| 151 | attributes: UpdateAttributes { |
| 152 | whats_new: whats_new.to_string(), |
| 153 | }, |
| 154 | }, |
| 155 | }; |
| 156 | let doc: Document<BetaBuildLocalizationAttributes> = |
| 157 | self.request(Method::PATCH, &path, &[], Some(&body)).await?; |
| 158 | Ok(doc.data) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | #[cfg(test)] |
| 163 | mod tests { |
| 164 | use super::*; |
| 165 | use smbcloud_ascapi_core::jsonapi::ListDocument; |
| 166 | |
| 167 | #[test] |
| 168 | fn deserializes_a_localization_list() { |
| 169 | let body = serde_json::json!({ |
| 170 | "data": [ |
| 171 | { |
| 172 | "id": "LOC1", |
| 173 | "type": "betaBuildLocalizations", |
| 174 | "attributes": { |
| 175 | "locale": "en-US", |
| 176 | "whatsNew": "Bug fixes and performance improvements." |
| 177 | } |
| 178 | }, |
| 179 | { |
| 180 | "id": "LOC2", |
| 181 | "type": "betaBuildLocalizations", |
| 182 | "attributes": { |
| 183 | "locale": "ja", |
| 184 | "whatsNew": "バグ修正とパフォーマンス改善。" |
| 185 | } |
| 186 | } |
| 187 | ] |
| 188 | }); |
| 189 | |
| 190 | let doc: ListDocument<BetaBuildLocalizationAttributes> = |
| 191 | serde_json::from_value(body).expect("localization list must deserialize"); |
| 192 | assert_eq!(doc.data.len(), 2); |
| 193 | |
| 194 | let en = &doc.data[0]; |
| 195 | assert_eq!(en.id, "LOC1"); |
| 196 | assert_eq!(en.attributes.locale.as_deref(), Some("en-US")); |
| 197 | assert_eq!( |
| 198 | en.attributes.whats_new.as_deref(), |
| 199 | Some("Bug fixes and performance improvements.") |
| 200 | ); |
| 201 | |
| 202 | let ja = &doc.data[1]; |
| 203 | assert_eq!(ja.attributes.locale.as_deref(), Some("ja")); |
| 204 | } |
| 205 | |
| 206 | #[test] |
| 207 | fn create_body_serializes_filter_key() { |
| 208 | // The filter key for the list endpoint must match exactly. |
| 209 | let query: Vec<(&str, &str)> = vec![("filter[build]", "BUILD1"), ("limit", "200")]; |
| 210 | assert_eq!(query[0].0, "filter[build]"); |
| 211 | assert_eq!(query[0].1, "BUILD1"); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn create_body_without_whats_new_omits_the_field() { |
| 216 | // whatsNew = None → field should be null in JSON (we serialize as |
| 217 | // `Option`, so it is present as null rather than absent, which ASC |
| 218 | // accepts as "no notes yet"). |
| 219 | let body = CreateBody { |
| 220 | data: CreateData { |
| 221 | resource_type: RESOURCE_TYPE, |
| 222 | attributes: CreateAttributes { |
| 223 | locale: "en-US".into(), |
| 224 | whats_new: None, |
| 225 | }, |
| 226 | relationships: None::<()>, |
| 227 | }, |
| 228 | }; |
| 229 | let v = serde_json::to_value(&body).unwrap(); |
| 230 | let attrs = &v["data"]["attributes"]; |
| 231 | assert_eq!(attrs["locale"], "en-US"); |
| 232 | assert!(attrs["whatsNew"].is_null()); |
| 233 | } |
| 234 | } |