Skip to main content

storekit/
error.rs

1use core::ffi::c_char;
2use std::collections::HashMap;
3use std::fmt;
4use std::sync::{Mutex, OnceLock};
5
6use serde::Deserialize;
7
8use crate::ffi;
9use crate::private::take_string;
10
11#[derive(Debug, Clone)]
12/// Top-level error returned by this crate for `StoreKit` failures.
13pub enum StoreKitError {
14    /// Represents the `InvalidArgument` `StoreKit` case.
15    InvalidArgument(String),
16    /// Represents the `TimedOut` `StoreKit` case.
17    TimedOut(String),
18    /// Represents the `NotSupported` `StoreKit` case.
19    NotSupported(String),
20    /// Represents the `Framework` `StoreKit` case.
21    Framework(StoreKitFrameworkError),
22    /// Represents the `Verification` `StoreKit` case.
23    Verification(VerificationFailure),
24    /// Preserves an unrecognized `StoreKit` case.
25    Unknown(String),
26}
27
28impl StoreKitError {
29    /// Returns the typed `StoreKit` framework error when one can be decoded.
30    pub fn typed(&self) -> Option<TypedStoreKitError> {
31        match self {
32            Self::Framework(error) => lookup_typed_framework_error(error),
33            _ => None,
34        }
35    }
36
37    /// Returns the decoded `StoreKit.StoreKitError`, if available.
38    pub fn storekit_api_error(&self) -> Option<StoreKitApiError> {
39        match self.typed() {
40            Some(TypedStoreKitError::StoreKit(error)) => Some(error),
41            _ => None,
42        }
43    }
44
45    /// Returns the decoded `StoreKit.Product.PurchaseError`, if available.
46    pub fn product_purchase_error(&self) -> Option<ProductPurchaseError> {
47        match self.typed() {
48            Some(TypedStoreKitError::Purchase(error)) => Some(error),
49            _ => None,
50        }
51    }
52
53    /// Returns the decoded `StoreKit.Transaction.RefundRequestError`, if available.
54    pub fn refund_request_error(&self) -> Option<RefundRequestError> {
55        match self.typed() {
56            Some(TypedStoreKitError::RefundRequest(error)) => Some(error),
57            _ => None,
58        }
59    }
60
61    /// Returns the decoded `StoreKit.InvalidRequestError`, if available.
62    pub fn invalid_request_error(&self) -> Option<InvalidRequestError> {
63        match self.typed() {
64            Some(TypedStoreKitError::InvalidRequest(error)) => Some(error),
65            _ => None,
66        }
67    }
68}
69
70impl fmt::Display for StoreKitError {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Self::InvalidArgument(message)
74            | Self::TimedOut(message)
75            | Self::NotSupported(message)
76            | Self::Unknown(message) => formatter.write_str(message),
77            Self::Framework(error) => write!(
78                formatter,
79                "{} (domain={}, code={})",
80                error.localized_description, error.domain, error.code
81            ),
82            Self::Verification(error) => write!(
83                formatter,
84                "{} ({})",
85                error.localized_description,
86                error.code.as_str()
87            ),
88        }
89    }
90}
91
92impl std::error::Error for StoreKitError {}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95/// Wraps the underlying `NSError` surfaced by the `StoreKit` framework.
96pub struct StoreKitFrameworkError {
97    /// `StoreKit`-provided `domain` value.
98    pub domain: String,
99    /// `StoreKit`-provided `code` value.
100    pub code: i64,
101    /// Localized description reported by `StoreKit`.
102    pub localized_description: String,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106/// Classifies typed error payloads returned by the `StoreKit` framework.
107pub enum TypedStoreKitError {
108    /// Represents the `StoreKit` `StoreKit` case.
109    StoreKit(StoreKitApiError),
110    /// Represents the `Purchase` `StoreKit` case.
111    Purchase(ProductPurchaseError),
112    /// Represents the `RefundRequest` `StoreKit` case.
113    RefundRequest(RefundRequestError),
114    /// Represents the `InvalidRequest` `StoreKit` case.
115    InvalidRequest(InvalidRequestError),
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119/// Wraps a `StoreKit.StoreKitError` payload.
120pub struct StoreKitApiError {
121    /// `StoreKit`-provided `code` value.
122    pub code: StoreKitApiErrorCode,
123    /// `StoreKit`-provided `error_description` value.
124    pub error_description: Option<String>,
125    /// `StoreKit`-provided `failure_reason` value.
126    pub failure_reason: Option<String>,
127    /// `StoreKit`-provided `recovery_suggestion` value.
128    pub recovery_suggestion: Option<String>,
129    /// `StoreKit`-provided `underlying_domain` value.
130    pub underlying_domain: Option<String>,
131    /// `StoreKit`-provided `underlying_code` value.
132    pub underlying_code: Option<i64>,
133    /// `StoreKit`-provided `underlying_description` value.
134    pub underlying_description: Option<String>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138/// Wraps the code space of `StoreKit.StoreKitError`.
139pub enum StoreKitApiErrorCode {
140    /// Preserves an unrecognized `StoreKit` case.
141    Unknown,
142    /// The person cancelled the `StoreKit` flow.
143    UserCancelled,
144    /// Represents the `NetworkError` `StoreKit` case.
145    NetworkError,
146    /// Represents the `SystemError` `StoreKit` case.
147    SystemError,
148    /// Represents the `NotAvailableInStorefront` `StoreKit` case.
149    NotAvailableInStorefront,
150    /// Represents the `NotEntitled` `StoreKit` case.
151    NotEntitled,
152    /// Represents the `Unsupported` `StoreKit` case.
153    Unsupported,
154    /// Represents the `other` `StoreKit` case.
155    Other(String),
156}
157
158impl StoreKitApiErrorCode {
159    /// Returns the raw `StoreKit` string for this error code.
160    pub fn as_str(&self) -> &str {
161        match self {
162            Self::Unknown => "unknown",
163            Self::UserCancelled => "userCancelled",
164            Self::NetworkError => "networkError",
165            Self::SystemError => "systemError",
166            Self::NotAvailableInStorefront => "notAvailableInStorefront",
167            Self::NotEntitled => "notEntitled",
168            Self::Unsupported => "unsupported",
169            Self::Other(value) => value.as_str(),
170        }
171    }
172
173    fn from_raw(raw: String) -> Self {
174        match raw.as_str() {
175            "unknown" => Self::Unknown,
176            "userCancelled" => Self::UserCancelled,
177            "networkError" => Self::NetworkError,
178            "systemError" => Self::SystemError,
179            "notAvailableInStorefront" => Self::NotAvailableInStorefront,
180            "notEntitled" => Self::NotEntitled,
181            "unsupported" => Self::Unsupported,
182            _ => Self::Other(raw),
183        }
184    }
185
186    const fn numeric_code(&self) -> i64 {
187        match self {
188            Self::Unknown => 0,
189            Self::UserCancelled => 1,
190            Self::NetworkError => 2,
191            Self::SystemError => 3,
192            Self::NotAvailableInStorefront => 4,
193            Self::NotEntitled => 5,
194            Self::Unsupported => 6,
195            Self::Other(_) => -1,
196        }
197    }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201/// Wraps a `StoreKit.Product.PurchaseError` payload.
202pub struct ProductPurchaseError {
203    /// `StoreKit`-provided `code` value.
204    pub code: ProductPurchaseErrorCode,
205    /// `StoreKit`-provided `error_description` value.
206    pub error_description: Option<String>,
207    /// `StoreKit`-provided `failure_reason` value.
208    pub failure_reason: Option<String>,
209    /// `StoreKit`-provided `recovery_suggestion` value.
210    pub recovery_suggestion: Option<String>,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214/// Wraps the code space of `StoreKit.Product.PurchaseError`.
215pub enum ProductPurchaseErrorCode {
216    /// Represents the `InvalidQuantity` `StoreKit` case.
217    InvalidQuantity,
218    /// Represents the `ProductUnavailable` `StoreKit` case.
219    ProductUnavailable,
220    /// Represents the `PurchaseNotAllowed` `StoreKit` case.
221    PurchaseNotAllowed,
222    /// Represents the `IneligibleForOffer` `StoreKit` case.
223    IneligibleForOffer,
224    /// Represents the `InvalidOfferIdentifier` `StoreKit` case.
225    InvalidOfferIdentifier,
226    /// Represents the `InvalidOfferPrice` `StoreKit` case.
227    InvalidOfferPrice,
228    /// Represents the `InvalidOfferSignature` `StoreKit` case.
229    InvalidOfferSignature,
230    /// Represents the `MissingOfferParameters` `StoreKit` case.
231    MissingOfferParameters,
232    /// Represents the `other` `StoreKit` case.
233    Other(String),
234}
235
236impl ProductPurchaseErrorCode {
237    /// Returns the raw `StoreKit` string for this purchase error code.
238    pub fn as_str(&self) -> &str {
239        match self {
240            Self::InvalidQuantity => "invalidQuantity",
241            Self::ProductUnavailable => "productUnavailable",
242            Self::PurchaseNotAllowed => "purchaseNotAllowed",
243            Self::IneligibleForOffer => "ineligibleForOffer",
244            Self::InvalidOfferIdentifier => "invalidOfferIdentifier",
245            Self::InvalidOfferPrice => "invalidOfferPrice",
246            Self::InvalidOfferSignature => "invalidOfferSignature",
247            Self::MissingOfferParameters => "missingOfferParameters",
248            Self::Other(value) => value.as_str(),
249        }
250    }
251
252    fn from_raw(raw: String) -> Self {
253        match raw.as_str() {
254            "invalidQuantity" => Self::InvalidQuantity,
255            "productUnavailable" => Self::ProductUnavailable,
256            "purchaseNotAllowed" => Self::PurchaseNotAllowed,
257            "ineligibleForOffer" => Self::IneligibleForOffer,
258            "invalidOfferIdentifier" => Self::InvalidOfferIdentifier,
259            "invalidOfferPrice" => Self::InvalidOfferPrice,
260            "invalidOfferSignature" => Self::InvalidOfferSignature,
261            "missingOfferParameters" => Self::MissingOfferParameters,
262            _ => Self::Other(raw),
263        }
264    }
265
266    const fn numeric_code(&self) -> i64 {
267        match self {
268            Self::InvalidQuantity => 0,
269            Self::ProductUnavailable => 1,
270            Self::PurchaseNotAllowed => 2,
271            Self::IneligibleForOffer => 3,
272            Self::InvalidOfferIdentifier => 4,
273            Self::InvalidOfferPrice => 5,
274            Self::InvalidOfferSignature => 6,
275            Self::MissingOfferParameters => 7,
276            Self::Other(_) => -1,
277        }
278    }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
282/// Wraps a `StoreKit.Transaction.RefundRequestError` payload.
283pub struct RefundRequestError {
284    /// `StoreKit`-provided `code` value.
285    pub code: RefundRequestErrorCode,
286    /// `StoreKit`-provided `error_description` value.
287    pub error_description: Option<String>,
288    /// `StoreKit`-provided `failure_reason` value.
289    pub failure_reason: Option<String>,
290    /// `StoreKit`-provided `recovery_suggestion` value.
291    pub recovery_suggestion: Option<String>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
295/// Wraps the code space of `StoreKit.Transaction.RefundRequestError`.
296pub enum RefundRequestErrorCode {
297    /// Represents the `DuplicateRequest` `StoreKit` case.
298    DuplicateRequest,
299    /// Represents the `Failed` `StoreKit` case.
300    Failed,
301    /// Represents the `other` `StoreKit` case.
302    Other(String),
303}
304
305impl RefundRequestErrorCode {
306    /// Returns the raw `StoreKit` string for this refund request error code.
307    pub fn as_str(&self) -> &str {
308        match self {
309            Self::DuplicateRequest => "duplicateRequest",
310            Self::Failed => "failed",
311            Self::Other(value) => value.as_str(),
312        }
313    }
314
315    fn from_raw(raw: String) -> Self {
316        match raw.as_str() {
317            "duplicateRequest" => Self::DuplicateRequest,
318            "failed" => Self::Failed,
319            _ => Self::Other(raw),
320        }
321    }
322
323    const fn numeric_code(&self) -> i64 {
324        match self {
325            Self::DuplicateRequest => 0,
326            Self::Failed => 1,
327            Self::Other(_) => -1,
328        }
329    }
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
333/// Wraps a `StoreKit.InvalidRequestError` payload.
334pub struct InvalidRequestError {
335    /// `StoreKit`-provided `code` value.
336    pub code: i64,
337    /// `StoreKit`-provided `message` value.
338    pub message: String,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
342/// Wraps the verification failure reported by `StoreKit`.
343pub struct VerificationFailure {
344    /// `StoreKit`-provided `code` value.
345    pub code: VerificationErrorCode,
346    /// Localized description reported by `StoreKit`.
347    pub localized_description: String,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
351/// Wraps the verification error codes used by `StoreKit`.
352pub enum VerificationErrorCode {
353    /// Represents the `RevokedCertificate` `StoreKit` case.
354    RevokedCertificate,
355    /// Represents the `InvalidCertificateChain` `StoreKit` case.
356    InvalidCertificateChain,
357    /// Represents the `InvalidDeviceVerification` `StoreKit` case.
358    InvalidDeviceVerification,
359    /// Represents the `InvalidEncoding` `StoreKit` case.
360    InvalidEncoding,
361    /// Represents the `InvalidSignature` `StoreKit` case.
362    InvalidSignature,
363    /// Represents the `MissingRequiredProperties` `StoreKit` case.
364    MissingRequiredProperties,
365    /// Preserves an unrecognized `StoreKit` case.
366    Unknown(String),
367}
368
369impl VerificationErrorCode {
370    /// Returns the raw `StoreKit` string for this verification error code.
371    pub fn as_str(&self) -> &str {
372        match self {
373            Self::RevokedCertificate => "revokedCertificate",
374            Self::InvalidCertificateChain => "invalidCertificateChain",
375            Self::InvalidDeviceVerification => "invalidDeviceVerification",
376            Self::InvalidEncoding => "invalidEncoding",
377            Self::InvalidSignature => "invalidSignature",
378            Self::MissingRequiredProperties => "missingRequiredProperties",
379            Self::Unknown(value) => value.as_str(),
380        }
381    }
382
383    pub(crate) fn from_raw(raw: String) -> Self {
384        match raw.as_str() {
385            "revokedCertificate" => Self::RevokedCertificate,
386            "invalidCertificateChain" => Self::InvalidCertificateChain,
387            "invalidDeviceVerification" => Self::InvalidDeviceVerification,
388            "invalidEncoding" => Self::InvalidEncoding,
389            "invalidSignature" => Self::InvalidSignature,
390            "missingRequiredProperties" => Self::MissingRequiredProperties,
391            _ => Self::Unknown(raw),
392        }
393    }
394}
395
396#[derive(Debug, Deserialize)]
397pub(crate) struct VerificationErrorPayload {
398    #[allow(dead_code)]
399    kind: String,
400    code: String,
401    #[serde(rename = "localizedDescription")]
402    localized_description: String,
403}
404
405impl VerificationFailure {
406    pub(crate) fn from_payload(payload: VerificationErrorPayload) -> Self {
407        Self {
408            code: VerificationErrorCode::from_raw(payload.code),
409            localized_description: payload.localized_description,
410        }
411    }
412}
413
414#[derive(Debug, Deserialize)]
415#[serde(tag = "kind")]
416enum FrameworkErrorPayload {
417    #[serde(rename = "framework")]
418    Framework {
419        domain: String,
420        code: i64,
421        #[serde(rename = "localizedDescription")]
422        localized_description: String,
423    },
424    #[serde(rename = "storekitError")]
425    StoreKit {
426        code: String,
427        #[serde(rename = "errorDescription")]
428        error_description: Option<String>,
429        #[serde(rename = "failureReason")]
430        failure_reason: Option<String>,
431        #[serde(rename = "recoverySuggestion")]
432        recovery_suggestion: Option<String>,
433        #[serde(rename = "underlyingDomain")]
434        underlying_domain: Option<String>,
435        #[serde(rename = "underlyingCode")]
436        underlying_code: Option<i64>,
437        #[serde(rename = "underlyingDescription")]
438        underlying_description: Option<String>,
439    },
440    #[serde(rename = "purchaseError")]
441    Purchase {
442        code: String,
443        #[serde(rename = "errorDescription")]
444        error_description: Option<String>,
445        #[serde(rename = "failureReason")]
446        failure_reason: Option<String>,
447        #[serde(rename = "recoverySuggestion")]
448        recovery_suggestion: Option<String>,
449    },
450    #[serde(rename = "refundRequestError")]
451    RefundRequest {
452        code: String,
453        #[serde(rename = "errorDescription")]
454        error_description: Option<String>,
455        #[serde(rename = "failureReason")]
456        failure_reason: Option<String>,
457        #[serde(rename = "recoverySuggestion")]
458        recovery_suggestion: Option<String>,
459    },
460    #[serde(rename = "invalidRequestError")]
461    InvalidRequest { code: i64, message: String },
462}
463
464impl FrameworkErrorPayload {
465    fn into_storekit_error(self) -> StoreKitError {
466        match self {
467            Self::Framework {
468                domain,
469                code,
470                localized_description,
471            } => StoreKitError::Framework(StoreKitFrameworkError {
472                domain,
473                code,
474                localized_description,
475            }),
476            Self::StoreKit {
477                code,
478                error_description,
479                failure_reason,
480                recovery_suggestion,
481                underlying_domain,
482                underlying_code,
483                underlying_description,
484            } => typed_framework_error(
485                "StoreKit.StoreKitError",
486                StoreKitApiErrorCode::from_raw(code.clone()).numeric_code(),
487                error_description.clone().unwrap_or_else(|| {
488                    StoreKitApiErrorCode::from_raw(code.clone())
489                        .as_str()
490                        .to_owned()
491                }),
492                TypedStoreKitError::StoreKit(StoreKitApiError {
493                    code: StoreKitApiErrorCode::from_raw(code),
494                    error_description,
495                    failure_reason,
496                    recovery_suggestion,
497                    underlying_domain,
498                    underlying_code,
499                    underlying_description,
500                }),
501            ),
502            Self::Purchase {
503                code,
504                error_description,
505                failure_reason,
506                recovery_suggestion,
507            } => typed_framework_error(
508                "StoreKit.Product.PurchaseError",
509                ProductPurchaseErrorCode::from_raw(code.clone()).numeric_code(),
510                error_description.clone().unwrap_or_else(|| {
511                    ProductPurchaseErrorCode::from_raw(code.clone())
512                        .as_str()
513                        .to_owned()
514                }),
515                TypedStoreKitError::Purchase(ProductPurchaseError {
516                    code: ProductPurchaseErrorCode::from_raw(code),
517                    error_description,
518                    failure_reason,
519                    recovery_suggestion,
520                }),
521            ),
522            Self::RefundRequest {
523                code,
524                error_description,
525                failure_reason,
526                recovery_suggestion,
527            } => typed_framework_error(
528                "StoreKit.Transaction.RefundRequestError",
529                RefundRequestErrorCode::from_raw(code.clone()).numeric_code(),
530                error_description.clone().unwrap_or_else(|| {
531                    RefundRequestErrorCode::from_raw(code.clone())
532                        .as_str()
533                        .to_owned()
534                }),
535                TypedStoreKitError::RefundRequest(RefundRequestError {
536                    code: RefundRequestErrorCode::from_raw(code),
537                    error_description,
538                    failure_reason,
539                    recovery_suggestion,
540                }),
541            ),
542            Self::InvalidRequest { code, message } => typed_framework_error(
543                "StoreKit.InvalidRequestError",
544                code,
545                message.clone(),
546                TypedStoreKitError::InvalidRequest(InvalidRequestError { code, message }),
547            ),
548        }
549    }
550}
551
552pub(crate) unsafe fn from_swift(status: i32, err_msg: *mut c_char) -> StoreKitError {
553    let message = take_string(err_msg);
554    match status {
555        ffi::status::INVALID_ARGUMENT => StoreKitError::InvalidArgument(
556            message.unwrap_or_else(|| "StoreKit reported an invalid argument".to_owned()),
557        ),
558        ffi::status::TIMED_OUT => StoreKitError::TimedOut(
559            message.unwrap_or_else(|| "StoreKit operation timed out".to_owned()),
560        ),
561        ffi::status::NOT_SUPPORTED => StoreKitError::NotSupported(
562            message.unwrap_or_else(|| "StoreKit operation is not supported".to_owned()),
563        ),
564        ffi::status::FRAMEWORK_ERROR => parse_framework_error(message),
565        ffi::status::VERIFICATION_ERROR => parse_verification_error(message),
566        _ => StoreKitError::Unknown(
567            message
568                .unwrap_or_else(|| format!("StoreKit bridge returned unexpected status {status}")),
569        ),
570    }
571}
572
573fn parse_framework_error(message: Option<String>) -> StoreKitError {
574    message.map_or_else(
575        || {
576            StoreKitError::Framework(StoreKitFrameworkError {
577                domain: "StoreKit".to_owned(),
578                code: i64::from(ffi::status::FRAMEWORK_ERROR),
579                localized_description: "StoreKit framework error".to_owned(),
580            })
581        },
582        |json| {
583            serde_json::from_str::<FrameworkErrorPayload>(&json).map_or_else(
584                |_| {
585                    StoreKitError::Framework(StoreKitFrameworkError {
586                        domain: "StoreKit".to_owned(),
587                        code: i64::from(ffi::status::FRAMEWORK_ERROR),
588                        localized_description: json,
589                    })
590                },
591                FrameworkErrorPayload::into_storekit_error,
592            )
593        },
594    )
595}
596
597fn parse_verification_error(message: Option<String>) -> StoreKitError {
598    message.map_or_else(
599        || {
600            StoreKitError::Verification(VerificationFailure {
601                code: VerificationErrorCode::Unknown("unknown".to_owned()),
602                localized_description: "StoreKit verification failed".to_owned(),
603            })
604        },
605        |json| {
606            serde_json::from_str::<VerificationErrorPayload>(&json).map_or_else(
607                |_| {
608                    StoreKitError::Verification(VerificationFailure {
609                        code: VerificationErrorCode::Unknown("unknown".to_owned()),
610                        localized_description: json,
611                    })
612                },
613                |payload| StoreKitError::Verification(VerificationFailure::from_payload(payload)),
614            )
615        },
616    )
617}
618
619fn typed_framework_error(
620    domain: &str,
621    code: i64,
622    localized_description: String,
623    typed_error: TypedStoreKitError,
624) -> StoreKitError {
625    let framework_error = StoreKitFrameworkError {
626        domain: domain.to_owned(),
627        code,
628        localized_description,
629    };
630    register_typed_framework_error(&framework_error, typed_error);
631    StoreKitError::Framework(framework_error)
632}
633
634fn register_typed_framework_error(error: &StoreKitFrameworkError, typed_error: TypedStoreKitError) {
635    typed_framework_error_registry()
636        .lock()
637        .expect("typed StoreKit error registry poisoned")
638        .insert(framework_error_key(error), typed_error);
639}
640
641fn lookup_typed_framework_error(error: &StoreKitFrameworkError) -> Option<TypedStoreKitError> {
642    typed_framework_error_registry()
643        .lock()
644        .expect("typed StoreKit error registry poisoned")
645        .get(&framework_error_key(error))
646        .cloned()
647}
648
649fn framework_error_key(error: &StoreKitFrameworkError) -> String {
650    format!("{}\u{0}{}\u{0}", error.domain, error.code) + &error.localized_description
651}
652
653fn typed_framework_error_registry() -> &'static Mutex<HashMap<String, TypedStoreKitError>> {
654    static REGISTRY: OnceLock<Mutex<HashMap<String, TypedStoreKitError>>> = OnceLock::new();
655    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn parses_product_purchase_errors_into_typed_details() {
664        let error = parse_framework_error(Some(
665            r#"{"kind":"purchaseError","code":"invalidQuantity","errorDescription":"bad quantity","failureReason":"quantity must be positive","recoverySuggestion":"choose a quantity greater than zero"}"#
666                .to_owned(),
667        ));
668        let typed = error
669            .product_purchase_error()
670            .expect("typed product purchase error");
671        assert_eq!(typed.code, ProductPurchaseErrorCode::InvalidQuantity);
672        assert_eq!(typed.error_description.as_deref(), Some("bad quantity"));
673    }
674
675    #[test]
676    fn parses_storekit_errors_into_typed_details() {
677        let error = parse_framework_error(Some(
678            r#"{"kind":"storekitError","code":"unsupported","errorDescription":"unsupported","failureReason":"not available here","recoverySuggestion":"try a supported storefront"}"#
679                .to_owned(),
680        ));
681        let typed = error
682            .storekit_api_error()
683            .expect("typed StoreKit API error");
684        assert_eq!(typed.code, StoreKitApiErrorCode::Unsupported);
685        assert_eq!(typed.failure_reason.as_deref(), Some("not available here"));
686    }
687
688    #[test]
689    fn parses_invalid_request_errors_into_typed_details() {
690        let error = parse_framework_error(Some(
691            r#"{"kind":"invalidRequestError","code":47,"message":"bad request"}"#.to_owned(),
692        ));
693        let typed = error
694            .invalid_request_error()
695            .expect("typed invalid request error");
696        assert_eq!(typed.code, 47);
697        assert_eq!(typed.message, "bad request");
698    }
699}