Skip to main content

smbcloud_ascapi_aso/
beta_build_localization.rs

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
8use async_trait::async_trait;
9use reqwest::Method;
10use serde::{Deserialize, Serialize};
11use smbcloud_ascapi_core::jsonapi::{
12    CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
13};
14use smbcloud_ascapi_core::Client;
15use smbcloud_ascapi_core::Result;
16
17pub 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")]
22pub 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.
30pub type BetaBuildLocalization = Resource<BetaBuildLocalizationAttributes>;
31
32// ── internal create/update helpers ───────────────────────────────────────────
33
34#[derive(Debug, Clone, Serialize)]
35#[serde(rename_all = "camelCase")]
36struct CreateAttributes {
37    locale: String,
38    whats_new: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize)]
42struct CreateRelationships {
43    build: ToOne,
44}
45
46#[derive(Debug, Clone, Serialize)]
47#[serde(rename_all = "camelCase")]
48struct 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]
61pub 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]
93impl 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)]
163mod 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}