main
rs 234 lines 8.1 KB
Raw
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 }