hcaptcha_no_wasm/
response.rs

1//! Structure to capture the response from the hcaptcha api
2//!
3//! ## Example
4//!
5//! ```no_run
6//! #   use hcaptcha_no_wasm::{Request, Client};
7//! # #[tokio::main]
8//! # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
9//! # let request = Request::new(
10//! #    "0x123456789abcedf0123456789abcdef012345678",
11//! #    get_captcha(),
12//! # )?;
13//! # let client = Client::new();
14//!     let response = client.verify(request).await?;
15//!
16//!     if let Some(timestamp) = response.timestamp() {
17//!         println!("Timestamp: {}", timestamp);
18//!     };
19//!     if let Some(hostname) = response.hostname() {
20//!         println!("Timestamp: {}", hostname);
21//!     };
22//!     if let Some(credit) = response.credit() {
23//!         println!("Timestamp: {}", credit);
24//!     };
25//!     // Only available with an Enterprise subscription to Hcaptcha
26//! # #[cfg(feature = "enterprise")]
27//!     if let Some(score) = response.score() {
28//!         println!("Score: {}", score);
29//!     };
30//! # #[cfg(feature = "enterprise")]
31//!     if let Some(score_reason) = response.score_reason() {
32//!         println!("Score reasons: {:?}", score_reason);
33//!     };
34//!
35//! # Ok(())
36//! # }
37//! # use hcaptcha_no_wasm::Captcha;
38//! # use rand::distributions::Alphanumeric;
39//! # use rand::{thread_rng, Rng};
40//! # use std::iter;
41//! # fn random_response() -> String {
42//! #    let mut rng = thread_rng();
43//! #    iter::repeat(())
44//! #        .map(|()| rng.sample(Alphanumeric))
45//! #        .map(char::from)
46//! #        .take(100)
47//! #        .collect()
48//! # }
49//! # fn get_captcha() -> Captcha {
50//! #    Captcha::new(&random_response())
51//! #       .unwrap()
52//! #       .set_remoteip(&mockd::internet::ipv4_address())
53//! #       .unwrap()
54//! #       .set_sitekey(&mockd::unique::uuid_v4())
55//! #       .unwrap()
56//! #       }
57
58//! ```
59use crate::Code;
60use crate::Error;
61use serde::Deserialize;
62use std::collections::HashSet;
63use std::fmt;
64
65type Score = f32;
66
67/// Result from call to verify the client's response
68#[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
69#[derive(Debug, Default, Deserialize, Clone)]
70pub struct Response {
71    /// verification status: true or false.
72    ///
73    /// Successful verification may have additional information.
74    /// Unsuccessful verification will return a set of error codes.
75    success: bool,
76    /// timestamp of the captcha (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
77    challenge_ts: Option<String>, //yyyy-MM-dd'T'HH:mm:ssZZ
78    /// the hostname of the site where the captcha was solved
79    hostname: Option<String>,
80    /// optional: whether the response will be credited
81    credit: Option<bool>,
82    /// optional: any error codes
83    #[serde(rename = "error-codes")]
84    error_codes: Option<HashSet<Code>>,
85    /// `enterprise` feature: a score denoting malicious activity.
86    #[allow(dead_code)]
87    #[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
88    score: Option<Score>,
89    /// `enterprise` feature: reason(s) for score. See [BotStop.com] for details
90    ///
91    /// [BotStop.com]: https://BotStop.com
92    #[allow(dead_code)]
93    #[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
94    score_reason: Option<HashSet<String>>,
95}
96
97#[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
98#[cfg(feature = "enterprise")]
99impl fmt::Display for Response {
100    #[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        write!(
103            f,
104            r#"
105        Status:         {}
106        Timestamp:      {}
107        Hostname:       {}
108        Credit:         {}
109        Error Codes:    {}
110        Score:          {}
111        Score Reason:   {}
112        "#,
113            self.success,
114            match self.timestamp() {
115                Some(v) => v,
116                None => "".to_owned(),
117            },
118            match self.hostname() {
119                Some(v) => v,
120                None => "".to_owned(),
121            },
122            match self.credit() {
123                Some(v) => format!("{v}"),
124                None => "".to_owned(),
125            },
126            match self.error_codes() {
127                Some(v) => format!("{v:?}"),
128                None => "".to_owned(),
129            },
130            match self.score() {
131                Some(v) => format!("{v}"),
132                None => "".to_owned(),
133            },
134            match self.score_reason() {
135                Some(v) => format!("{v:?}"),
136                None => "".to_owned(),
137            },
138        )
139    }
140}
141
142#[cfg(not(feature = "enterprise"))]
143impl fmt::Display for Response {
144    #[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        write!(
147            f,
148            r#"
149        Status:         {}
150        Timestamp:      {}
151        Hostname:       {}
152        Credit:         {}
153        Error Codes:    {}
154        "#,
155            self.success,
156            match self.timestamp() {
157                Some(v) => v,
158                None => "".to_owned(),
159            },
160            match self.hostname() {
161                Some(v) => v,
162                None => "".to_owned(),
163            },
164            match self.credit() {
165                Some(v) => format!("{}", v),
166                None => "".to_owned(),
167            },
168            match self.error_codes() {
169                Some(v) => format!("{:?}", v),
170                None => "".to_owned(),
171            },
172        )
173    }
174}
175
176#[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
177impl Response {
178    /// Check success of API call and return Error
179    /// with the error codes if not successful.
180    pub(crate) fn check_error(&self) -> Result<(), Error> {
181        if !self.success() {
182            match &self.error_codes {
183                Some(codes) => Err(Error::Codes(codes.clone())),
184                None => {
185                    let mut codes = HashSet::new();
186                    codes.insert(Code::Unknown("No error codes returned".to_owned()));
187                    Err(Error::Codes(codes))
188                }
189            }
190        } else {
191            Ok(())
192        }
193    }
194
195    /// Get the value of the success field
196    ///
197    /// # Example
198    /// ```no_run
199    /// #   use hcaptcha_no_wasm::{Request, Client};
200    /// # #[tokio::main]
201    /// # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
202    /// # let request = Request::new(
203    /// #    "0x123456789abcedf0123456789abcdef012345678",
204    /// #    get_captcha(),
205    /// # )?;
206    /// # let client = Client::new();
207    ///     let response = client.verify(request).await?;
208    ///     println!("Success returns true: {}", response.success());
209    /// # Ok(())
210    /// # }
211    /// # use hcaptcha_no_wasm::Captcha;
212    /// # use rand::distributions::Alphanumeric;
213    /// # use rand::{thread_rng, Rng};
214    /// # use std::iter;
215    /// # fn random_response() -> String {
216    /// #    let mut rng = thread_rng();
217    /// #    iter::repeat(())
218    /// #        .map(|()| rng.sample(Alphanumeric))
219    /// #        .map(char::from)
220    /// #        .take(100)
221    /// #        .collect()
222    /// # }
223    /// # fn get_captcha() -> Captcha {
224    /// #    Captcha::new(&random_response())
225    /// #       .unwrap()
226    /// #       .set_remoteip(&mockd::internet::ipv4_address())
227    /// #       .unwrap()
228    /// #       .set_sitekey(&mockd::unique::uuid_v4())
229    /// #       .unwrap()
230    /// #       }
231    #[allow(dead_code)]
232    pub fn success(&self) -> bool {
233        self.success
234    }
235
236    /// Get the value of the hostname field
237    ///
238    /// # Example
239    /// ```no_run
240    /// #   use hcaptcha_no_wasm::{Request, Client};
241    /// # #[tokio::main]
242    /// # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
243    /// # let request = Request::new(
244    /// #    "0x123456789abcedf0123456789abcdef012345678",
245    /// #    get_captcha(),
246    /// # )?;
247    /// # let client = Client::new();
248    ///     let response = client.verify(request).await?;
249    ///
250    ///     if let Some(hostname) = response.hostname() {
251    ///         println!("Timestamp: {}", hostname);
252    ///     };
253    /// # Ok(())
254    /// # }
255    /// # use hcaptcha_no_wasm::Captcha;
256    /// # use rand::distributions::Alphanumeric;
257    /// # use rand::{thread_rng, Rng};
258    /// # use std::iter;
259    /// # fn random_response() -> String {
260    /// #    let mut rng = thread_rng();
261    /// #    iter::repeat(())
262    /// #        .map(|()| rng.sample(Alphanumeric))
263    /// #        .map(char::from)
264    /// #        .take(100)
265    /// #        .collect()
266    /// # }
267    /// # fn get_captcha() -> Captcha {
268    /// #    Captcha::new(&random_response())
269    /// #       .unwrap()
270    /// #       .set_remoteip(&mockd::internet::ipv4_address())
271    /// #       .unwrap()
272    /// #       .set_sitekey(&mockd::unique::uuid_v4())
273    /// #       .unwrap()
274    /// #       }
275    #[allow(dead_code)]
276    pub fn hostname(&self) -> Option<String> {
277        self.hostname.clone()
278    }
279
280    /// Get the value of the timestamp field
281    ///
282    /// # Example
283    /// ```no_run
284    /// #   use hcaptcha_no_wasm::{Request, Client};
285    /// # #[tokio::main]
286    /// # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
287    /// # let request = Request::new(
288    /// #    "0x123456789abcedf0123456789abcdef012345678",
289    /// #    get_captcha(),
290    /// # )?;
291    /// # let client = Client::new();
292    ///     let response = client.verify(request).await?;
293    ///
294    ///     if let Some(timestamp) = response.timestamp() {
295    ///         println!("Timestamp: {}", timestamp);
296    ///     };
297    /// # Ok(())
298    /// # }
299    /// # use hcaptcha_no_wasm::Captcha;
300    /// # use rand::distributions::Alphanumeric;
301    /// # use rand::{thread_rng, Rng};
302    /// # use std::iter;
303    /// # fn random_response() -> String {
304    /// #    let mut rng = thread_rng();
305    /// #    iter::repeat(())
306    /// #        .map(|()| rng.sample(Alphanumeric))
307    /// #        .map(char::from)
308    /// #        .take(100)
309    /// #        .collect()
310    /// # }
311    /// # fn get_captcha() -> Captcha {
312    /// #    Captcha::new(&random_response())
313    /// #       .unwrap()
314    /// #       .set_remoteip(&mockd::internet::ipv4_address())
315    /// #       .unwrap()
316    /// #       .set_sitekey(&mockd::unique::uuid_v4())
317    /// #       .unwrap()
318    /// #       }
319    #[allow(dead_code)]
320    pub fn timestamp(&self) -> Option<String> {
321        self.challenge_ts.clone()
322    }
323
324    /// Get the value of the credit field
325    ///
326    /// # Example
327    /// ```no_run
328    /// #   use hcaptcha_no_wasm::{Request, Client};
329    /// # #[tokio::main]
330    /// # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
331    /// # let request = Request::new(
332    /// #    "0x123456789abcedf0123456789abcdef012345678",
333    /// #    get_captcha(),
334    /// # )?;
335    /// # let client = Client::new();
336    ///     let response = client.verify(request).await?;
337    ///
338    ///     if let Some(credit) = response.credit() {
339    ///         println!("Timestamp: {}", credit);
340    ///     };
341    ///
342    /// # Ok(())
343    /// # }
344    /// # use hcaptcha_no_wasm::Captcha;
345    /// # use rand::distributions::Alphanumeric;
346    /// # use rand::{thread_rng, Rng};
347    /// # use std::iter;
348    /// # fn random_response() -> String {
349    /// #    let mut rng = thread_rng();
350    /// #    iter::repeat(())
351    /// #        .map(|()| rng.sample(Alphanumeric))
352    /// #        .map(char::from)
353    /// #        .take(100)
354    /// #        .collect()
355    /// # }
356    /// # fn get_captcha() -> Captcha {
357    /// #    Captcha::new(&random_response())
358    /// #       .unwrap()
359    /// #       .set_remoteip(&mockd::internet::ipv4_address())
360    /// #       .unwrap()
361    /// #       .set_sitekey(&mockd::unique::uuid_v4())
362    /// #       .unwrap()
363    /// # }
364    #[allow(dead_code)]
365    pub fn credit(&self) -> Option<bool> {
366        self.credit
367    }
368
369    /// Get the value of the error_codes field
370    ///
371    /// # Example
372    /// ```no_run
373    /// #   use hcaptcha_no_wasm::{Request, Client};
374    /// # #[tokio::main]
375    /// # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
376    /// # let request = Request::new(
377    /// #    "0x123456789abcedf0123456789abcdef012345678",
378    /// #    get_captcha(),
379    /// # )?;
380    /// # let client = Client::new();
381    ///     let response = client.verify(request).await?;
382    ///
383    ///     if let Some(error_codes) = response.error_codes() {
384    ///         println!("Error Codes: {:?}", error_codes);
385    ///     };
386    ///
387    /// # Ok(())
388    /// # }
389    /// # use hcaptcha_no_wasm::Captcha;
390    /// # use rand::distributions::Alphanumeric;
391    /// # use rand::{thread_rng, Rng};
392    /// # use std::iter;
393    /// # fn random_response() -> String {
394    /// #    let mut rng = thread_rng();
395    /// #    iter::repeat(())
396    /// #        .map(|()| rng.sample(Alphanumeric))
397    /// #        .map(char::from)
398    /// #        .take(100)
399    /// #        .collect()
400    /// # }
401    /// # fn get_captcha() -> Captcha {
402    /// #    Captcha::new(&random_response())
403    /// #       .unwrap()
404    /// #       .set_remoteip(&mockd::internet::ipv4_address())
405    /// #       .unwrap()
406    /// #       .set_sitekey(&mockd::unique::uuid_v4())
407    /// #       .unwrap()
408    /// #       }
409    #[allow(dead_code)]
410    pub fn error_codes(&self) -> Option<HashSet<Code>> {
411        self.error_codes.clone()
412    }
413
414    /// Get the value of the score field
415    ///
416    /// # Example
417    /// ```no_run
418    /// #   use hcaptcha_no_wasm::{Request, Client};
419    /// # #[tokio::main]
420    /// # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
421    /// # let request = Request::new(
422    /// #    "0x123456789abcedf0123456789abcdef012345678",
423    /// #    get_captcha(),
424    /// # )?;
425    /// # let client = Client::new();
426    ///     let response = client.verify(request).await?;
427    ///
428    ///     if let Some(score) = response.score() {
429    ///         println!("Score: {}", score);
430    ///     };
431    ///
432    /// # Ok(())
433    /// # }
434    /// # use hcaptcha_no_wasm::Captcha;
435    /// # use rand::distributions::Alphanumeric;
436    /// # use rand::{thread_rng, Rng};
437    /// # use std::iter;
438    /// # fn random_response() -> String {
439    /// #    let mut rng = thread_rng();
440    /// #    iter::repeat(())
441    /// #        .map(|()| rng.sample(Alphanumeric))
442    /// #        .map(char::from)
443    /// #        .take(100)
444    /// #        .collect()
445    /// # }
446    /// # fn get_captcha() -> Captcha {
447    /// #    Captcha::new(&random_response())
448    /// #       .unwrap()
449    /// #       .set_remoteip(&mockd::internet::ipv4_address())
450    /// #       .unwrap()
451    /// #       .set_sitekey(&mockd::unique::uuid_v4())
452    /// #       .unwrap()
453    /// #       }
454    #[cfg(feature = "enterprise")]
455    #[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
456    #[allow(dead_code)]
457    pub fn score(&self) -> Option<Score> {
458        self.score
459    }
460
461    /// Get the value of the score_reason field
462    ///
463    /// # Example
464    /// ```no_run
465    /// #   use hcaptcha_no_wasm::{Request, Client};
466    /// # #[tokio::main]
467    /// # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
468    /// # let request = Request::new(
469    /// #    "0x123456789abcedf0123456789abcdef012345678",
470    /// #    get_captcha(),
471    /// # )?;
472    /// # let client = Client::new();
473    ///     let response = client.verify(request).await?;
474    ///
475    ///     if let Some(score_reason) = response.score_reason() {
476    ///         println!("Score reasons: {:?}", score_reason);
477    ///     };
478    ///
479    /// # Ok(())
480    /// # }
481    /// # use hcaptcha_no_wasm::Captcha;
482    /// # use rand::distributions::Alphanumeric;
483    /// # use rand::{thread_rng, Rng};
484    /// # use std::iter;
485    /// # fn random_response() -> String {
486    /// #    let mut rng = thread_rng();
487    /// #    iter::repeat(())
488    /// #        .map(|()| rng.sample(Alphanumeric))
489    /// #        .map(char::from)
490    /// #        .take(100)
491    /// #        .collect()
492    /// # }
493    /// # fn get_captcha() -> Captcha {
494    /// #    Captcha::new(&random_response())
495    /// #       .unwrap()
496    /// #       .set_remoteip(&mockd::internet::ipv4_address())
497    /// #       .unwrap()
498    /// #       .set_sitekey(&mockd::unique::uuid_v4())
499    /// #       .unwrap()
500    /// #       }
501    #[allow(dead_code)]
502    #[cfg(feature = "enterprise")]
503    #[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
504    pub fn score_reason(&self) -> Option<HashSet<String>> {
505        self.score_reason.clone()
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use std::collections::HashSet;
512
513    use crate::{Code, Error, Response};
514    use serde_json::json;
515
516    #[test]
517    fn decoding_test() {
518        use crate::Code::*;
519
520        let response = json!({
521            "success": true,
522            "error-codes": ["missing-input-secret", "foo"],
523            "hostname": "hostname"
524        });
525        let response: Response = serde_json::from_value(response).unwrap();
526
527        assert!(response.success);
528        assert!(response.error_codes.is_some());
529
530        let errors = response.error_codes.unwrap();
531        assert!(errors.len() == 2);
532        assert!(errors.contains(&MissingSecret));
533        assert!(errors.contains(&Unknown("foo".to_string())));
534    }
535
536    fn test_response() -> Response {
537        let response = json!({
538            "success": true,
539            "challenge_ts": "2020-11-11T23:27:00Z",
540            "hostname": "my-host.ie",
541            "credit": false,
542            "error-codes": ["missing-input-secret", "foo"],
543            "score": null,
544            "score_reason": ["first-reason", "second-reason"],
545        });
546        serde_json::from_value(response).unwrap()
547    }
548
549    #[test]
550    fn success_test() {
551        let response = test_response();
552
553        assert!(response.success());
554    }
555
556    #[test]
557    fn timestamp_test() {
558        let response = test_response();
559
560        assert_eq!(
561            response.timestamp(),
562            Some("2020-11-11T23:27:00Z".to_owned())
563        );
564    }
565
566    #[test]
567    fn hostname_test() {
568        let response = test_response();
569
570        assert_eq!(response.hostname(), Some("my-host.ie".to_owned()));
571    }
572
573    #[test]
574    fn credit_test() {
575        let response = test_response();
576
577        assert_eq!(response.credit(), Some(false));
578    }
579
580    #[test]
581    fn error_codes_test() {
582        let response = test_response();
583
584        assert!(response.error_codes().is_some());
585        if let Some(hash_set) = response.error_codes() {
586            assert_eq!(hash_set.len(), 2)
587        }
588    }
589
590    #[cfg(feature = "enterprise")]
591    #[test]
592    fn score_test() {
593        let response = test_response();
594
595        assert!(response.score().is_none());
596    }
597
598    #[cfg(feature = "enterprise")]
599    #[test]
600    fn score_reason_test() {
601        let response = test_response();
602
603        assert!(response.score_reason().is_some());
604        if let Some(hash_set) = response.score_reason() {
605            assert!(!hash_set.is_empty());
606            assert!(hash_set.contains("first-reason"));
607            assert!(hash_set.contains("second-reason"));
608        }
609    }
610
611    #[test]
612    fn test_successful_decoding_with_error_codes() {
613        use crate::Code::*;
614
615        let response = json!({
616            "success": true,
617            "error-codes": ["missing-input-secret", "foo"],
618            "hostname": "hostname"
619        });
620        let response: Response = serde_json::from_value(response).unwrap();
621
622        assert!(response.success);
623        assert!(response.error_codes.is_some());
624
625        let errors = response.error_codes.unwrap();
626        assert!(errors.len() == 2);
627        assert!(errors.contains(&MissingSecret));
628        assert!(errors.contains(&Unknown("foo".to_string())));
629    }
630
631    #[test]
632    fn test_error_codes_handling() {
633        use crate::Code::*;
634
635        let response = json!({
636            "success": true,
637            "error-codes": ["missing-input-secret", "foo"],
638            "hostname": "hostname"
639        });
640        let response: Response = serde_json::from_value(response).unwrap();
641
642        let errors = response.error_codes.unwrap();
643        assert!(errors.contains(&MissingSecret));
644        assert!(errors.contains(&Unknown("foo".to_string())));
645    }
646
647    #[test]
648    fn test_success_field_decoding() {
649        let response = json!({
650            "success": true,
651            "error-codes": ["missing-input-secret", "foo"],
652            "hostname": "hostname"
653        });
654        let response: Response = serde_json::from_value(response).unwrap();
655
656        assert!(response.success);
657    }
658
659    #[cfg(feature = "enterprise")]
660    #[test]
661    fn test_display_format_enterprise() {
662        {
663            let mut codes = HashSet::new();
664            codes.insert(Code::MissingSecret);
665            let mut reasons = HashSet::new();
666            reasons.insert("reason1".to_string());
667
668            let response = Response {
669                success: true,
670                challenge_ts: Some("2023-01-01T00:00:00Z".to_string()),
671                hostname: Some("test.com".to_string()),
672                credit: Some(true),
673                error_codes: Some(codes),
674                score: Some(0.9),
675                score_reason: Some(reasons),
676            };
677
678            let formatted = format!("{}", response);
679            println!("{}", formatted);
680            assert!(formatted.contains("Status:         true"));
681            assert!(formatted.contains("Timestamp:      2023-01-01T00:00:00Z"));
682            assert!(formatted.contains("Hostname:       test.com"));
683            assert!(formatted.contains("Credit:         true"));
684            assert!(formatted.contains(r#"Error Codes:    {MissingSecret}"#));
685            assert!(formatted.contains("Score:          0.9"));
686            assert!(formatted.contains(r#"Score Reason:   {"reason1"}"#));
687        }
688    }
689
690    #[cfg(not(feature = "enterprise"))]
691    #[test]
692    fn test_display_format_non_enterprise() {
693        {
694            let mut codes = HashSet::new();
695            codes.insert(Code::MissingSecret);
696
697            let response = Response {
698                success: false,
699                challenge_ts: Some("2023-01-01T00:00:00Z".to_string()),
700                hostname: Some("test.com".to_string()),
701                credit: Some(false),
702                error_codes: Some(codes),
703                score: None,
704                score_reason: None,
705            };
706
707            let formatted = format!("{}", response);
708            assert!(formatted.contains("Status:         false"));
709            assert!(formatted.contains("Timestamp:      2023-01-01T00:00:00Z"));
710            assert!(formatted.contains("Hostname:       test.com"));
711            assert!(formatted.contains("Credit:         false"));
712            assert!(formatted.contains(r#"Error Codes:    {MissingSecret}"#));
713        }
714    }
715
716    #[test]
717    fn test_display_format_empty_fields() {
718        let response = Response {
719            success: true,
720            challenge_ts: None,
721            hostname: None,
722            credit: None,
723            error_codes: None,
724            score: None,
725            score_reason: None,
726        };
727
728        let formatted = format!("{}", response);
729        assert!(formatted.contains("Status:         true"));
730        assert!(formatted.contains("Timestamp:      "));
731        assert!(formatted.contains("Hostname:       "));
732        assert!(formatted.contains("Credit:         "));
733        assert!(formatted.contains("Error Codes:    "));
734    }
735
736    #[test]
737    fn test_check_error_success() {
738        let response = Response {
739            success: true,
740            challenge_ts: None,
741            hostname: None,
742            credit: None,
743            error_codes: None,
744            score: None,
745            score_reason: None,
746        };
747        assert!(response.check_error().is_ok());
748    }
749
750    #[test]
751    fn test_check_error_with_codes() {
752        let mut error_codes = HashSet::new();
753        error_codes.insert(Code::MissingResponse);
754        let response = Response {
755            success: false,
756            challenge_ts: None,
757            hostname: None,
758            credit: None,
759            error_codes: Some(error_codes.clone()),
760            score: None,
761            score_reason: None,
762        };
763        match response.check_error() {
764            Err(Error::Codes(codes)) => {
765                assert_eq!(codes, error_codes);
766            }
767            _ => unreachable!(),
768        }
769    }
770
771    #[test]
772    fn test_check_error_without_codes() {
773        let response = Response {
774            success: false,
775            challenge_ts: None,
776            hostname: None,
777            credit: None,
778            error_codes: None,
779            score: None,
780            score_reason: None,
781        };
782
783        match response.check_error() {
784            Err(Error::Codes(codes)) => {
785                assert_eq!(codes.len(), 1);
786                assert!(codes.iter().any(
787                    |code| matches!(code, Code::Unknown(msg) if msg == "No error codes returned")
788                ));
789            }
790            _ => unreachable!(),
791        }
792    }
793}