Skip to main content

wepub_core/firefox/
api.rs

1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use reqwest::multipart::{Form, Part};
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8use crate::{Result, WepubError, http::build_client};
9
10use super::auth::generate_jwt;
11
12const DEFAULT_ROOT_URL: &str = "https://addons.mozilla.org/";
13const UPLOAD_FILE_NAME: &str = "addon.zip";
14const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(1);
15const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_secs(5 * 60);
16
17/// Options that shape how [`FirefoxStore::publish`] creates the new version.
18#[derive(Debug, Clone, Default)]
19pub struct FirefoxPublishOptions {
20    /// Distribution channel for the new version.
21    pub channel: Channel,
22    /// Application compatibility declarations. `None` falls back to whatever
23    /// the manifest's `strict_min_version` / `strict_max_version` declare.
24    pub compatibility: Option<Compatibility>,
25    /// Release notes keyed by AMO locale code (e.g. `"en-US"`).
26    pub release_notes: HashMap<String, String>,
27    /// Optional message to AMO reviewers, typically containing build
28    /// reproduction steps.
29    pub approval_notes: Option<String>,
30    /// Optional source archive to attach to the version. AMO requires this
31    /// when reviewers cannot reproduce the bundled artefact from the listing.
32    pub source: Option<Vec<u8>>,
33    /// Polling cadence and overall timeout used while waiting for AMO to
34    /// finish validating the upload.
35    pub poll: FirefoxPollConfig,
36}
37
38/// Polling cadence and budget for [`FirefoxStore::publish`]'s
39/// validation-status loop.
40///
41/// Defaults to 1 second interval and 5 minute timeout.
42#[derive(Debug, Clone)]
43pub struct FirefoxPollConfig {
44    /// Delay between successive polls of the upload status endpoint.
45    pub interval: Duration,
46    /// Maximum total time to wait before giving up with
47    /// [`WepubError::Validation`].
48    pub timeout: Duration,
49}
50
51impl Default for FirefoxPollConfig {
52    fn default() -> Self {
53        Self {
54            interval: DEFAULT_POLL_INTERVAL,
55            timeout: DEFAULT_POLL_TIMEOUT,
56        }
57    }
58}
59
60/// Distribution channel for an AMO version.
61#[derive(Debug, Clone, Copy, Default)]
62pub enum Channel {
63    /// Listed on addons.mozilla.org. Goes through public review (the
64    /// default).
65    #[default]
66    Listed,
67    /// Self-distributed signed build. Reviewed but not listed.
68    Unlisted,
69}
70
71impl Channel {
72    fn as_str(self) -> &'static str {
73        match self {
74            Channel::Listed => "listed",
75            Channel::Unlisted => "unlisted",
76        }
77    }
78}
79
80/// Compatibility declaration sent to AMO when creating the version.
81///
82/// AMO's wire format accepts either a flat list of compatible apps (with
83/// versions inferred from the manifest) or an object mapping each app to an
84/// explicit version range. `wepub-core` exposes both shapes through this
85/// enum.
86#[derive(Debug, Clone)]
87pub enum Compatibility {
88    /// Shorthand form: list compatible apps; min/max come from the manifest.
89    Apps(Vec<Application>),
90    /// Detailed form: per-app explicit version range. An empty
91    /// [`VersionRange`] means "use the value declared in the manifest".
92    Detailed(HashMap<Application, VersionRange>),
93}
94
95impl Serialize for Compatibility {
96    fn serialize<S: serde::Serializer>(
97        &self,
98        serializer: S,
99    ) -> std::result::Result<S::Ok, S::Error> {
100        match self {
101            Self::Apps(apps) => apps.serialize(serializer),
102            Self::Detailed(map) => map.serialize(serializer),
103        }
104    }
105}
106
107/// AMO application identifier used in compatibility declarations.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub enum Application {
110    /// Desktop Firefox.
111    Firefox,
112    /// Firefox for Android.
113    Android,
114}
115
116impl Application {
117    fn as_str(self) -> &'static str {
118        match self {
119            Application::Firefox => "firefox",
120            Application::Android => "android",
121        }
122    }
123}
124
125impl Serialize for Application {
126    fn serialize<S: serde::Serializer>(
127        &self,
128        serializer: S,
129    ) -> std::result::Result<S::Ok, S::Error> {
130        serializer.serialize_str(self.as_str())
131    }
132}
133
134/// Explicit `min` / `max` application version pair used by
135/// [`Compatibility::Detailed`].
136///
137/// Either bound can be `None`, in which case the corresponding manifest
138/// value is used.
139#[derive(Debug, Clone, Default, Serialize)]
140pub struct VersionRange {
141    /// Minimum compatible application version. `None` defers to the
142    /// manifest's `strict_min_version`.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub min: Option<String>,
145    /// Maximum compatible application version. `None` defers to the
146    /// manifest's `strict_max_version`.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub max: Option<String>,
149}
150
151// Successful response from creating a new add-on version on AMO.
152// Internal-only: the id is echoed via `tracing::info!` from `publish` and
153// not surfaced to the caller because the only documented use was logging.
154#[derive(Debug, Clone, Deserialize)]
155pub(crate) struct VersionResponse {
156    pub(crate) id: u64,
157}
158
159/// Client for the AMO Add-on Versions API (v5).
160///
161/// The store holds the JWT credential pair and a reusable HTTP client; it
162/// is cheap to construct and intended to live for the duration of a single
163/// publish run.
164// Debug intentionally omitted: holds the AMO JWT secret.
165pub struct FirefoxStore {
166    addon_id: String,
167    issuer: String,
168    secret: String,
169    root_url: Url,
170    client: reqwest::Client,
171}
172
173impl FirefoxStore {
174    /// Build a store bound to `addon_id`, signing requests with the supplied
175    /// HS256 JWT credential pair (issuer + secret).
176    ///
177    /// Get the credentials from
178    /// <https://addons.mozilla.org/developers/addon/api/key/>.
179    ///
180    /// # Errors
181    ///
182    /// Fails if the underlying HTTP client cannot be built (e.g. rustls
183    /// platform-verifier initialization fails).
184    pub fn from_jwt_credentials(
185        addon_id: String,
186        jwt_issuer: String,
187        jwt_secret: String,
188    ) -> Result<Self> {
189        Ok(Self {
190            addon_id,
191            issuer: jwt_issuer,
192            secret: jwt_secret,
193            root_url: Url::parse(DEFAULT_ROOT_URL).expect("DEFAULT_ROOT_URL is a valid URL"),
194            client: build_client()?,
195        })
196    }
197
198    /// Override the AMO API root URL.
199    ///
200    /// Defaults to `https://addons.mozilla.org/`. Intended for tests
201    /// or when pointing at a local `mozilla/addons-server` instance. A
202    /// missing trailing slash is added automatically so that relative paths
203    /// join correctly.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`WepubError::InvalidUrl`] if `root_url` does not parse as a
208    /// URL.
209    pub fn with_root_url(mut self, root_url: &str) -> Result<Self> {
210        let parsed = Url::parse(root_url)
211            .map_err(|e| WepubError::InvalidUrl(format!("{root_url:?}: {e}")))?;
212        self.root_url = ensure_trailing_slash(parsed);
213        Ok(self)
214    }
215
216    /// Upload `zip` and create a new version on the bound add-on.
217    ///
218    /// The call performs four steps internally: upload the archive, poll
219    /// AMO until validation finishes, create the version, and (if
220    /// `options.source` is set) attach the source archive in a follow-up
221    /// PATCH. The polling cadence is controlled by `options.poll`.
222    ///
223    /// Progress (`uploading...`, `polling AMO upload status`, `published
224    /// version id=...`) is emitted through the `tracing` crate; library
225    /// consumers configure their own subscriber to render or capture it.
226    ///
227    /// # Errors
228    ///
229    /// On failure, returns one of [`WepubError::Network`],
230    /// [`WepubError::Api`], [`WepubError::Auth`], [`WepubError::Validation`],
231    /// [`WepubError::Json`], [`WepubError::Io`] or [`WepubError::Internal`]
232    /// depending on which step failed.
233    ///
234    /// # Examples
235    ///
236    /// ```no_run
237    /// # async fn run() -> wepub_core::Result<()> {
238    /// use wepub_core::firefox::{FirefoxStore, FirefoxPublishOptions};
239    ///
240    /// let store = FirefoxStore::from_jwt_credentials(
241    ///     "myaddon@example.com".into(),
242    ///     "user:12345:6789".into(),
243    ///     "jwt-secret".into(),
244    /// )?;
245    /// let zip = std::fs::read("./addon.zip")?;
246    /// store.publish(zip, FirefoxPublishOptions::default()).await?;
247    /// # Ok(())
248    /// # }
249    /// ```
250    pub async fn publish(&self, zip: Vec<u8>, options: FirefoxPublishOptions) -> Result<()> {
251        let upload = self.upload(zip, options.channel).await?;
252        let validated = self
253            .wait_until_validated(&upload.uuid, &options.poll)
254            .await?;
255
256        let version = self
257            .create_version(
258                &validated.uuid,
259                options.compatibility.as_ref(),
260                &options.release_notes,
261                options.approval_notes.as_deref(),
262            )
263            .await?;
264
265        if let Some(source) = options.source {
266            self.patch_version_source(version.id, source).await?;
267        }
268
269        tracing::info!(version_id = version.id, "published version");
270        Ok(())
271    }
272
273    pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
274        self.root_url
275            .join(path)
276            .map_err(|e| WepubError::Internal(format!("invalid endpoint path {path:?}: {e}")))
277    }
278
279    pub(crate) async fn upload(&self, zip: Vec<u8>, channel: Channel) -> Result<UploadResponse> {
280        let url = self.endpoint("api/v5/addons/upload/")?;
281        let auth = self.auth_header()?;
282
283        let len = zip.len() as u64;
284        let part = Part::stream_with_length(reqwest::Body::from(zip), len)
285            .file_name(UPLOAD_FILE_NAME)
286            .mime_str("application/zip")
287            .map_err(|e| WepubError::Internal(format!("invalid MIME literal: {e}")))?;
288        let form = Form::new()
289            .part("upload", part)
290            .text("channel", channel.as_str());
291
292        tracing::info!(addon_id = %self.addon_id, channel = channel.as_str(), "uploading add-on to AMO");
293
294        let resp = self
295            .client
296            .post(url)
297            .header(reqwest::header::AUTHORIZATION, auth)
298            .multipart(form)
299            .send()
300            .await?;
301
302        decode_response(resp).await
303    }
304
305    pub(crate) async fn wait_until_validated(
306        &self,
307        uuid: &str,
308        config: &FirefoxPollConfig,
309    ) -> Result<UploadResponse> {
310        let url = self.endpoint(&format!("api/v5/addons/upload/{uuid}/"))?;
311        let started = Instant::now();
312
313        loop {
314            let auth = self.auth_header()?;
315            let resp = self
316                .client
317                .get(url.clone())
318                .header(reqwest::header::AUTHORIZATION, auth)
319                .send()
320                .await?;
321            let upload: UploadResponse = decode_response(resp).await?;
322
323            tracing::info!(
324                uuid = uuid,
325                processed = upload.processed,
326                valid = upload.valid,
327                "polling AMO upload status"
328            );
329
330            if upload.processed {
331                if upload.valid {
332                    return Ok(upload);
333                }
334                let body = upload.validation.as_ref().map_or_else(
335                    || "validation failed (no detail provided)".to_string(),
336                    |v| serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()),
337                );
338                return Err(WepubError::Validation {
339                    uuid: uuid.to_string(),
340                    body,
341                });
342            }
343
344            if started.elapsed() >= config.timeout {
345                return Err(WepubError::Validation {
346                    uuid: uuid.to_string(),
347                    body: format!("validation timed out after {:?}", config.timeout),
348                });
349            }
350
351            tokio::time::sleep(config.interval).await;
352        }
353    }
354
355    pub(crate) async fn create_version(
356        &self,
357        upload_uuid: &str,
358        compatibility: Option<&Compatibility>,
359        release_notes: &HashMap<String, String>,
360        approval_notes: Option<&str>,
361    ) -> Result<VersionResponse> {
362        let url = self.endpoint(&format!("api/v5/addons/addon/{}/versions/", self.addon_id))?;
363        let auth = self.auth_header()?;
364
365        let body = VersionCreateBody {
366            upload: upload_uuid,
367            compatibility,
368            release_notes,
369            approval_notes,
370        };
371
372        tracing::info!(
373            addon_id = %self.addon_id,
374            uuid = upload_uuid,
375            "creating AMO version"
376        );
377
378        let resp = self
379            .client
380            .post(url)
381            .header(reqwest::header::AUTHORIZATION, auth)
382            .json(&body)
383            .send()
384            .await?;
385
386        decode_response(resp).await
387    }
388
389    pub(crate) async fn patch_version_source(
390        &self,
391        version_id: u64,
392        source: Vec<u8>,
393    ) -> Result<VersionResponse> {
394        let url = self.endpoint(&format!(
395            "api/v5/addons/addon/{}/versions/{version_id}/",
396            self.addon_id
397        ))?;
398        let auth = self.auth_header()?;
399
400        let len = source.len() as u64;
401        let part = Part::stream_with_length(reqwest::Body::from(source), len)
402            .file_name("source.zip")
403            .mime_str("application/zip")
404            .map_err(|e| WepubError::Internal(format!("invalid MIME literal: {e}")))?;
405        let form = Form::new().part("source", part);
406
407        tracing::info!(
408            addon_id = %self.addon_id,
409            version_id,
410            "uploading version source to AMO"
411        );
412
413        let resp = self
414            .client
415            .patch(url)
416            .header(reqwest::header::AUTHORIZATION, auth)
417            .multipart(form)
418            .send()
419            .await?;
420
421        decode_response(resp).await
422    }
423
424    fn auth_header(&self) -> Result<String> {
425        let token = generate_jwt(&self.issuer, &self.secret)?;
426        Ok(format!("JWT {token}"))
427    }
428}
429
430#[derive(Debug, Clone, Deserialize)]
431pub(crate) struct UploadResponse {
432    pub uuid: String,
433    pub processed: bool,
434    pub valid: bool,
435    #[serde(default)]
436    pub validation: Option<serde_json::Value>,
437}
438
439#[derive(Serialize)]
440struct VersionCreateBody<'a> {
441    upload: &'a str,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    compatibility: Option<&'a Compatibility>,
444    #[serde(skip_serializing_if = "HashMap::is_empty")]
445    release_notes: &'a HashMap<String, String>,
446    #[serde(skip_serializing_if = "Option::is_none")]
447    approval_notes: Option<&'a str>,
448}
449
450async fn decode_response<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
451    let status = resp.status();
452    let body = resp.text().await?;
453    tracing::debug!(
454        status = status.as_u16(),
455        body = %body,
456        "received AMO response",
457    );
458    if !status.is_success() {
459        return Err(WepubError::Api {
460            status: status.as_u16(),
461            body,
462        });
463    }
464    serde_json::from_str(&body).map_err(WepubError::from)
465}
466
467fn ensure_trailing_slash(mut url: Url) -> Url {
468    if !url.path().ends_with('/') {
469        let new_path = format!("{}/", url.path());
470        url.set_path(&new_path);
471    }
472    url
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use serde_json::json;
479    use wiremock::matchers::{header_exists, method, path};
480    use wiremock::{Mock, MockServer, ResponseTemplate};
481
482    #[tokio::test]
483    async fn upload_posts_multipart_and_parses_response() {
484        let server = MockServer::start().await;
485        Mock::given(method("POST"))
486            .and(path("/api/v5/addons/upload/"))
487            .and(header_exists("authorization"))
488            .respond_with(
489                ResponseTemplate::new(201).set_body_json(upload_json("abc-123", false, false)),
490            )
491            .expect(1)
492            .mount(&server)
493            .await;
494
495        let store = store_for(&server);
496        let resp = store
497            .upload(b"fake-zip".to_vec(), Channel::Listed)
498            .await
499            .unwrap();
500
501        assert_eq!(resp.uuid, "abc-123");
502        assert!(!resp.processed);
503    }
504
505    #[tokio::test]
506    async fn wait_until_validated_returns_when_processed_and_valid() {
507        let server = MockServer::start().await;
508
509        Mock::given(method("GET"))
510            .and(path("/api/v5/addons/upload/uuid-1/"))
511            .respond_with(
512                ResponseTemplate::new(200).set_body_json(upload_json("uuid-1", false, false)),
513            )
514            .up_to_n_times(1)
515            .mount(&server)
516            .await;
517
518        Mock::given(method("GET"))
519            .and(path("/api/v5/addons/upload/uuid-1/"))
520            .respond_with(
521                ResponseTemplate::new(200).set_body_json(upload_json("uuid-1", true, true)),
522            )
523            .mount(&server)
524            .await;
525
526        let store = store_for(&server);
527        let resp = store
528            .wait_until_validated("uuid-1", &fast_poll())
529            .await
530            .unwrap();
531
532        assert_eq!(resp.uuid, "uuid-1");
533        assert!(resp.processed);
534        assert!(resp.valid);
535    }
536
537    #[tokio::test]
538    async fn wait_until_validated_errors_on_invalid_validation() {
539        let server = MockServer::start().await;
540        let body = json!({
541            "uuid": "uuid-2",
542            "channel": "listed",
543            "processed": true,
544            "submitted": false,
545            "url": "https://example.com/upload/uuid-2/",
546            "valid": false,
547            "validation": { "messages": [{ "type": "error", "message": "manifest broken" }] },
548            "version": null,
549        });
550        Mock::given(method("GET"))
551            .and(path("/api/v5/addons/upload/uuid-2/"))
552            .respond_with(ResponseTemplate::new(200).set_body_json(body))
553            .mount(&server)
554            .await;
555
556        let store = store_for(&server);
557        let err = store
558            .wait_until_validated("uuid-2", &fast_poll())
559            .await
560            .unwrap_err();
561
562        match err {
563            WepubError::Validation { uuid, body } => {
564                assert_eq!(uuid, "uuid-2");
565                assert!(body.contains("manifest broken"));
566            }
567            other => panic!("expected WepubError::Validation, got {other:?}"),
568        }
569    }
570
571    #[tokio::test]
572    async fn wait_until_validated_times_out_when_processing_never_completes() {
573        let server = MockServer::start().await;
574        Mock::given(method("GET"))
575            .and(path("/api/v5/addons/upload/uuid-3/"))
576            .respond_with(
577                ResponseTemplate::new(200).set_body_json(upload_json("uuid-3", false, false)),
578            )
579            .mount(&server)
580            .await;
581
582        let store = store_for(&server);
583        let err = store
584            .wait_until_validated("uuid-3", &fast_poll())
585            .await
586            .unwrap_err();
587
588        match err {
589            WepubError::Validation { uuid, body } => {
590                assert_eq!(uuid, "uuid-3");
591                assert!(body.contains("timed out"));
592            }
593            other => panic!("expected WepubError::Validation, got {other:?}"),
594        }
595    }
596
597    #[tokio::test]
598    async fn create_version_posts_json_and_parses_id() {
599        let server = MockServer::start().await;
600        Mock::given(method("POST"))
601            .and(path("/api/v5/addons/addon/test-addon/versions/"))
602            .and(header_exists("authorization"))
603            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 4242 })))
604            .expect(1)
605            .mount(&server)
606            .await;
607
608        let store = store_for(&server);
609        let resp = store
610            .create_version("uuid-x", None, &HashMap::new(), None)
611            .await
612            .unwrap();
613
614        assert_eq!(resp.id, 4242);
615    }
616
617    #[tokio::test]
618    async fn patch_version_source_sends_multipart_patch() {
619        let server = MockServer::start().await;
620        Mock::given(method("PATCH"))
621            .and(path("/api/v5/addons/addon/test-addon/versions/4242/"))
622            .and(header_exists("authorization"))
623            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 4242 })))
624            .expect(1)
625            .mount(&server)
626            .await;
627
628        let store = store_for(&server);
629        let resp = store
630            .patch_version_source(4242, b"source-zip".to_vec())
631            .await
632            .unwrap();
633
634        assert_eq!(resp.id, 4242);
635    }
636
637    #[tokio::test]
638    async fn publish_runs_full_flow_when_source_is_provided() {
639        let server = MockServer::start().await;
640
641        Mock::given(method("POST"))
642            .and(path("/api/v5/addons/upload/"))
643            .respond_with(
644                ResponseTemplate::new(201).set_body_json(upload_json("uuid-pub", false, false)),
645            )
646            .expect(1)
647            .mount(&server)
648            .await;
649
650        Mock::given(method("GET"))
651            .and(path("/api/v5/addons/upload/uuid-pub/"))
652            .respond_with(
653                ResponseTemplate::new(200).set_body_json(upload_json("uuid-pub", true, true)),
654            )
655            .mount(&server)
656            .await;
657
658        Mock::given(method("POST"))
659            .and(path("/api/v5/addons/addon/test-addon/versions/"))
660            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 7777 })))
661            .expect(1)
662            .mount(&server)
663            .await;
664
665        Mock::given(method("PATCH"))
666            .and(path("/api/v5/addons/addon/test-addon/versions/7777/"))
667            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 7777 })))
668            .expect(1)
669            .mount(&server)
670            .await;
671
672        let store = store_for(&server);
673        let options = FirefoxPublishOptions {
674            source: Some(b"source-zip".to_vec()),
675            poll: fast_poll(),
676            ..FirefoxPublishOptions::default()
677        };
678        store.publish(b"zip".to_vec(), options).await.unwrap();
679    }
680
681    #[tokio::test]
682    async fn publish_skips_source_patch_when_no_source_provided() {
683        let server = MockServer::start().await;
684
685        Mock::given(method("POST"))
686            .and(path("/api/v5/addons/upload/"))
687            .respond_with(
688                ResponseTemplate::new(201).set_body_json(upload_json("uuid-ns", true, true)),
689            )
690            .expect(1)
691            .mount(&server)
692            .await;
693
694        Mock::given(method("GET"))
695            .and(path("/api/v5/addons/upload/uuid-ns/"))
696            .respond_with(
697                ResponseTemplate::new(200).set_body_json(upload_json("uuid-ns", true, true)),
698            )
699            .mount(&server)
700            .await;
701
702        Mock::given(method("POST"))
703            .and(path("/api/v5/addons/addon/test-addon/versions/"))
704            .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 9999 })))
705            .expect(1)
706            .mount(&server)
707            .await;
708
709        Mock::given(method("PATCH"))
710            .and(path("/api/v5/addons/addon/test-addon/versions/9999/"))
711            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 9999 })))
712            .expect(0)
713            .mount(&server)
714            .await;
715
716        let store = store_for(&server);
717        let options = FirefoxPublishOptions {
718            poll: fast_poll(),
719            ..FirefoxPublishOptions::default()
720        };
721        store.publish(b"zip".to_vec(), options).await.unwrap();
722    }
723
724    #[tokio::test]
725    async fn publish_propagates_upload_api_error() {
726        let server = MockServer::start().await;
727        Mock::given(method("POST"))
728            .and(path("/api/v5/addons/upload/"))
729            .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
730            .expect(1)
731            .mount(&server)
732            .await;
733
734        let store = store_for(&server);
735        let options = FirefoxPublishOptions {
736            poll: fast_poll(),
737            ..FirefoxPublishOptions::default()
738        };
739        let err = store.publish(b"zip".to_vec(), options).await.unwrap_err();
740
741        match err {
742            WepubError::Api { status, body } => {
743                assert_eq!(status, 401);
744                assert_eq!(body, "unauthorized");
745            }
746            other => panic!("expected WepubError::Api, got {other:?}"),
747        }
748    }
749
750    // ---- Unit tests for serialization and URL helpers ----
751
752    #[test]
753    fn channel_serialises_as_amo_expects() {
754        assert_eq!(Channel::Listed.as_str(), "listed");
755        assert_eq!(Channel::Unlisted.as_str(), "unlisted");
756    }
757
758    #[test]
759    fn version_create_body_minimal_only_has_upload() {
760        let json = body_to_json("uuid-123", None, &HashMap::new(), None);
761        assert_eq!(json, serde_json::json!({ "upload": "uuid-123" }));
762    }
763
764    #[test]
765    fn version_create_body_with_apps_shorthand() {
766        let compat = Compatibility::Apps(vec![Application::Firefox, Application::Android]);
767        let json = body_to_json("uuid-123", Some(&compat), &HashMap::new(), None);
768        assert_eq!(
769            json,
770            serde_json::json!({
771                "upload": "uuid-123",
772                "compatibility": ["firefox", "android"],
773            })
774        );
775    }
776
777    #[test]
778    fn version_create_body_with_detailed_compatibility_omits_empty_min_max() {
779        let mut map = HashMap::new();
780        map.insert(
781            Application::Firefox,
782            VersionRange {
783                min: Some("58.0".into()),
784                max: Some("120.0".into()),
785            },
786        );
787        map.insert(
788            Application::Android,
789            VersionRange {
790                min: Some("58.0".into()),
791                max: None,
792            },
793        );
794        let compat = Compatibility::Detailed(map);
795        let json = body_to_json("uuid-123", Some(&compat), &HashMap::new(), None);
796
797        assert_eq!(json["upload"], "uuid-123");
798        assert_eq!(
799            json["compatibility"]["firefox"],
800            serde_json::json!({ "min": "58.0", "max": "120.0" })
801        );
802        assert_eq!(
803            json["compatibility"]["android"],
804            serde_json::json!({ "min": "58.0" })
805        );
806    }
807
808    #[test]
809    fn version_create_body_includes_release_notes_and_approval_notes() {
810        let mut notes = HashMap::new();
811        notes.insert("en-US".into(), "Hello".into());
812        notes.insert("ja".into(), "こんにちは".into());
813
814        let json = body_to_json("uuid-123", None, &notes, Some("for reviewers"));
815
816        assert_eq!(json["upload"], "uuid-123");
817        assert_eq!(json["release_notes"]["en-US"], "Hello");
818        assert_eq!(json["release_notes"]["ja"], "こんにちは");
819        assert_eq!(json["approval_notes"], "for reviewers");
820    }
821
822    #[test]
823    fn endpoint_joins_relative_path() {
824        let store = FirefoxStore::from_jwt_credentials(
825            "test-addon".into(),
826            "issuer".into(),
827            "secret".into(),
828        )
829        .unwrap();
830        let url = store.endpoint("api/v5/addons/upload/").unwrap();
831        assert_eq!(
832            url.as_str(),
833            "https://addons.mozilla.org/api/v5/addons/upload/"
834        );
835    }
836
837    #[test]
838    fn with_root_url_overrides_default() {
839        let store = FirefoxStore::from_jwt_credentials(
840            "test-addon".into(),
841            "issuer".into(),
842            "secret".into(),
843        )
844        .unwrap()
845        .with_root_url("http://127.0.0.1:8000/")
846        .unwrap();
847        let url = store.endpoint("api/v5/addons/upload/").unwrap();
848        assert_eq!(url.as_str(), "http://127.0.0.1:8000/api/v5/addons/upload/");
849    }
850
851    #[test]
852    fn with_root_url_rejects_garbage() {
853        let store = FirefoxStore::from_jwt_credentials(
854            "test-addon".into(),
855            "issuer".into(),
856            "secret".into(),
857        )
858        .unwrap();
859        let Err(err) = store.with_root_url("not a url") else {
860            panic!("expected with_root_url to reject");
861        };
862        assert!(matches!(err, WepubError::InvalidUrl(_)), "got {err:?}");
863    }
864
865    #[test]
866    fn ensure_trailing_slash_appends_when_missing() {
867        let url = Url::parse("https://example.com/api/v5").unwrap();
868        let result = ensure_trailing_slash(url);
869        assert_eq!(result.as_str(), "https://example.com/api/v5/");
870    }
871
872    #[test]
873    fn ensure_trailing_slash_is_idempotent() {
874        let url = Url::parse("https://example.com/api/v5/").unwrap();
875        let result = ensure_trailing_slash(url);
876        assert_eq!(result.as_str(), "https://example.com/api/v5/");
877    }
878
879    fn store_for(server: &MockServer) -> FirefoxStore {
880        FirefoxStore::from_jwt_credentials("test-addon".into(), "issuer".into(), "secret".into())
881            .unwrap()
882            .with_root_url(&server.uri())
883            .unwrap()
884    }
885
886    fn fast_poll() -> FirefoxPollConfig {
887        FirefoxPollConfig {
888            interval: Duration::from_millis(10),
889            timeout: Duration::from_millis(200),
890        }
891    }
892
893    fn upload_json(uuid: &str, processed: bool, valid: bool) -> serde_json::Value {
894        json!({
895            "uuid": uuid,
896            "channel": "listed",
897            "processed": processed,
898            "submitted": false,
899            "url": format!("https://example.com/upload/{uuid}/"),
900            "valid": valid,
901            "validation": null,
902            "version": "1.0.0",
903        })
904    }
905
906    fn body_to_json(
907        upload: &str,
908        compatibility: Option<&Compatibility>,
909        release_notes: &HashMap<String, String>,
910        approval_notes: Option<&str>,
911    ) -> serde_json::Value {
912        serde_json::to_value(VersionCreateBody {
913            upload,
914            compatibility,
915            release_notes,
916            approval_notes,
917        })
918        .unwrap()
919    }
920}