expect_json/expect/ops/
expect_iso_date_time.rs

1use crate::JsonType;
2use crate::expect::ops::utils::DurationFormatter;
3use crate::expect_core::Context;
4use crate::expect_core::ExpectOp;
5use crate::expect_core::ExpectOpError;
6use crate::expect_core::ExpectOpResult;
7use crate::expect_core::expect_op;
8use chrono::DateTime;
9use chrono::Duration as ChronoDuration;
10use chrono::FixedOffset;
11use chrono::Offset;
12use chrono::Utc;
13use std::time::Duration as StdDuration;
14
15///
16/// Expects an ISO 8601 date time string.
17///
18/// By _default_ this accepts any timezone.
19///
20/// ```rust
21/// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
22/// #
23/// # use axum::Router;
24/// # use axum::extract::Json;
25/// # use axum::routing::get;
26/// # use axum_test::TestServer;
27/// # use serde_json::json;
28/// #
29/// # let server = TestServer::new(Router::new())?;
30/// #
31/// use std::time::Duration;
32/// use axum_test::expect_json;
33///
34/// let server = TestServer::new(Router::new())?;
35///
36/// server.get(&"/latest-comment")
37///     .await
38///     .assert_json(&json!({
39///         "comment": "My example comment",
40///         "created_at": expect_json::iso_date_time(),
41///     }));
42/// #
43/// # Ok(()) }
44/// ```
45///
46#[expect_op(internal, name = "iso_date_time")]
47#[derive(Debug, Clone, Default, PartialEq)]
48pub struct ExpectIsoDateTime {
49    is_utc_only: bool,
50    maybe_past_duration: Option<StdDuration>,
51    maybe_future_duration: Option<StdDuration>,
52}
53
54impl ExpectIsoDateTime {
55    pub(crate) fn new() -> Self {
56        Self {
57            is_utc_only: false,
58            maybe_past_duration: None,
59            maybe_future_duration: None,
60        }
61    }
62
63    ///
64    /// By default, `IsoDateTime` expects all date times to be in UTC.
65    ///
66    /// This method relaxes this constraint,
67    /// and will accept date times in any timezone.
68    ///
69    /// ```rust
70    /// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
71    /// #
72    /// # use axum::Router;
73    /// # use axum::extract::Json;
74    /// # use axum::routing::get;
75    /// # use axum_test::TestServer;
76    /// # use serde_json::json;
77    /// #
78    /// # let server = TestServer::new(Router::new())?;
79    /// #
80    /// use std::time::Duration;
81    /// use axum_test::expect_json;
82    ///
83    /// let server = TestServer::new(Router::new())?;
84    ///
85    /// server.get(&"/latest-comment")
86    ///     .await
87    ///     .assert_json(&json!({
88    ///         "comment": "My example comment",
89    ///
90    ///         // Users time may have any timezone
91    ///         "users_created_at": expect_json::iso_date_time().utc(),
92    ///     }));
93    /// #
94    /// # Ok(()) }
95    /// ```
96    ///
97    pub fn utc(self) -> Self {
98        Self {
99            is_utc_only: true,
100            ..self
101        }
102    }
103
104    ///
105    /// Expects the date time to be within a past duration,
106    /// up to the current time.
107    ///
108    /// The constraint will fail when:
109    ///  - the datetime is further in the past than the given duration,
110    ///  - or ahead of the current time.
111    ///
112    /// ```rust
113    /// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
114    /// #
115    /// # use axum::Router;
116    /// # use axum::extract::Json;
117    /// # use axum::routing::get;
118    /// # use axum_test::TestServer;
119    /// # use serde_json::json;
120    /// #
121    /// # let server = TestServer::new(Router::new())?;
122    /// #
123    /// use std::time::Duration;
124    /// use axum_test::expect_json;
125    ///
126    /// let server = TestServer::new(Router::new())?;
127    ///
128    /// server.get(&"/latest-comment")
129    ///     .await
130    ///     .assert_json(&json!({
131    ///         "comment": "My example comment",
132    ///
133    ///         // Expect it was updated in the last minute
134    ///         "updated_at": expect_json::iso_date_time()
135    ///             .within_past(Duration::from_secs(60)),
136    ///     }));
137    /// #
138    /// # Ok(()) }
139    /// ```
140    ///
141    pub fn within_past(self, duration: StdDuration) -> Self {
142        Self {
143            maybe_past_duration: Some(duration),
144            ..self
145        }
146    }
147
148    ///
149    /// Expects the date time to be within the current time,
150    /// and up to a future duration.
151    ///
152    /// The constraint will fail when:
153    ///  - the datetime is further ahead than the given duration,
154    ///  - or behind the current time.
155    ///
156    /// ```rust
157    /// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
158    /// #
159    /// # use axum::Router;
160    /// # use axum::extract::Json;
161    /// # use axum::routing::get;
162    /// # use axum_test::TestServer;
163    /// # use serde_json::json;
164    /// #
165    /// #
166    /// use std::time::Duration;
167    /// use axum_test::expect_json;
168    ///
169    /// let server = TestServer::new(Router::new())?;
170    ///
171    /// server.get(&"/latest-comment")
172    ///     .await
173    ///     .assert_json(&json!({
174    ///         "comment": "My example comment",
175    ///
176    ///         // Expect it also expires in the next minute
177    ///         "expires_at": expect_json::iso_date_time()
178    ///             .within_future(Duration::from_secs(60)),
179    ///     }));
180    /// #
181    /// # Ok(()) }
182    /// ```
183    ///
184    pub fn within_future(self, duration: StdDuration) -> Self {
185        Self {
186            maybe_future_duration: Some(duration),
187            ..self
188        }
189    }
190}
191
192impl ExpectOp for ExpectIsoDateTime {
193    fn on_string(&self, context: &mut Context, received: &str) -> ExpectOpResult<()> {
194        let date_time = DateTime::<FixedOffset>::parse_from_rfc3339(received).map_err(|error| {
195            let error_message = format!("failed to parse string '{received}' as iso date time");
196            ExpectOpError::custom_error(self, context, error_message, error)
197        })?;
198
199        if self.is_utc_only {
200            let is_date_time_utc = date_time.offset().fix().utc_minus_local() != 0;
201            if is_date_time_utc {
202                let error_message = format!(
203                    "ISO datetime '{received}' is using a non-UTC timezone, expected UTC only"
204                );
205                return Err(ExpectOpError::custom(self, context, error_message));
206            }
207        }
208
209        match (self.maybe_past_duration, self.maybe_future_duration) {
210            (None, None) => {}
211            (Some(past_duration), None) => {
212                let is_date_time_outside_past = date_time < Utc::now() - past_duration;
213                if is_date_time_outside_past {
214                    let duration =
215                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
216                    let error_message = format!(
217                        "ISO datetime '{received}' is too far from the past, expected between '{duration}' ago and now"
218                    );
219                    return Err(ExpectOpError::custom(self, context, error_message));
220                }
221
222                let is_date_time_ahead_of_now = date_time > Utc::now();
223                if is_date_time_ahead_of_now {
224                    let duration =
225                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
226                    let error_message = format!(
227                        "ISO datetime '{received}' is in the future of now, expected between '{duration}' ago and now"
228                    );
229                    return Err(ExpectOpError::custom(self, context, error_message));
230                }
231            }
232            (None, Some(future_duration)) => {
233                let is_date_time_outside_future = date_time > Utc::now() + future_duration;
234                if is_date_time_outside_future {
235                    let duration =
236                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
237                    let error_message = format!(
238                        "ISO datetime '{received}' is too far in the future, expected between now and '{duration}' in the future"
239                    );
240                    return Err(ExpectOpError::custom(self, context, error_message));
241                }
242
243                let is_date_time_behind_of_now = date_time < Utc::now();
244                if is_date_time_behind_of_now {
245                    let duration =
246                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
247                    let error_message = format!(
248                        "ISO datetime '{received}' is in the past of now, expected between now and '{duration}' in the future"
249                    );
250                    return Err(ExpectOpError::custom(self, context, error_message));
251                }
252            }
253            (Some(past_duration), Some(future_duration)) => {
254                let is_date_time_outside_past = date_time < Utc::now() - past_duration;
255                if is_date_time_outside_past {
256                    let duration =
257                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
258                    let error_message = format!(
259                        "ISO datetime '{received}' is too far from the past, expected between '{duration}' ago and now"
260                    );
261                    return Err(ExpectOpError::custom(self, context, error_message));
262                }
263
264                let is_date_time_outside_future = date_time > Utc::now() + future_duration;
265                if is_date_time_outside_future {
266                    let duration =
267                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
268                    let error_message = format!(
269                        "ISO datetime '{received}' is too far in the future, expected between now and '{duration}' in the future"
270                    );
271                    return Err(ExpectOpError::custom(self, context, error_message));
272                }
273            }
274        }
275
276        Ok(())
277    }
278
279    fn debug_supported_types(&self) -> &'static [JsonType] {
280        &[JsonType::String]
281    }
282}
283
284#[cfg(test)]
285mod test_iso_date_time {
286    use crate::expect;
287    use crate::expect_json_eq;
288    use pretty_assertions::assert_eq;
289    use serde_json::json;
290
291    #[test]
292    fn it_should_parse_iso_datetime_with_utc_timezone() {
293        let left = json!("2024-01-15T13:45:30Z");
294        let right = json!(expect::iso_date_time());
295
296        let output = expect_json_eq(&left, &right);
297        assert!(output.is_ok(), "assertion error: {output:#?}");
298    }
299
300    #[test]
301    fn it_should_parse_iso_datetime_with_non_utc_timezone_by_default() {
302        let left = json!("2024-01-15T13:45:30+01:00");
303        let right = json!(expect::iso_date_time());
304
305        let output = expect_json_eq(&left, &right);
306        assert!(output.is_ok(), "assertion error: {output:#?}");
307    }
308
309    #[test]
310    fn it_should_fail_to_parse_iso_datetime_without_timezone() {
311        let left = json!("2024-01-15T13:45:30");
312        let right = json!(expect::iso_date_time());
313
314        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
315        assert_eq!(
316            output,
317            r#"Json expect::iso_date_time() error at root:
318    failed to parse string '2024-01-15T13:45:30' as iso date time,
319    premature end of input"#
320        );
321    }
322}
323
324#[cfg(test)]
325mod test_utc {
326    use crate::expect;
327    use crate::expect_json_eq;
328    use pretty_assertions::assert_eq;
329    use serde_json::json;
330
331    #[test]
332    fn it_should_parse_iso_datetime_with_utc_timezone_when_set() {
333        let left = json!("2024-01-15T13:45:30Z");
334        let right = json!(expect::iso_date_time().utc());
335
336        let output = expect_json_eq(&left, &right);
337        assert!(output.is_ok(), "assertion error: {output:#?}");
338    }
339
340    #[test]
341    fn it_should_not_parse_iso_datetime_with_non_utc_timezone_when_set() {
342        let left = json!("2024-01-15T13:45:30+01:00");
343        let right = json!(expect::iso_date_time().utc());
344
345        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
346        assert_eq!(
347            output,
348            "Json expect::iso_date_time() error at root:
349    ISO datetime '2024-01-15T13:45:30+01:00' is using a non-UTC timezone, expected UTC only"
350        );
351    }
352}
353
354#[cfg(test)]
355mod test_within_past {
356    use super::*;
357    use crate::expect;
358    use crate::expect_json_eq;
359    use pretty_assertions::assert_eq;
360    use serde_json::json;
361
362    #[test]
363    fn it_should_parse_iso_datetime_within_past_set() {
364        let now = Utc::now();
365        let now_str = (now - ChronoDuration::seconds(30)).to_rfc3339();
366        let left = json!(now_str);
367        let right = json!(expect::iso_date_time().within_past(StdDuration::from_secs(60)));
368
369        let output = expect_json_eq(&left, &right);
370        assert!(output.is_ok(), "assertion error: {output:#?}");
371    }
372
373    #[test]
374    fn it_should_not_parse_iso_datetime_within_past_too_far() {
375        let now = Utc::now();
376        let now_str = (now - ChronoDuration::seconds(90)).to_rfc3339();
377        let left = json!(now_str);
378        let right = json!(expect::iso_date_time().within_past(StdDuration::from_secs(60)));
379
380        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
381        assert_eq!(
382            output,
383            format!(
384                r#"Json expect::iso_date_time() error at root:
385    ISO datetime '{now_str}' is too far from the past, expected between '1 minute' ago and now"#
386            )
387        );
388    }
389
390    #[test]
391    fn it_should_not_parse_iso_datetime_ahead_of_now() {
392        let now = Utc::now();
393        let now_str = (now + ChronoDuration::seconds(90)).to_rfc3339();
394        let left = json!(now_str);
395        let right = json!(expect::iso_date_time().within_past(StdDuration::from_secs(60)));
396
397        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
398        assert_eq!(
399            output,
400            format!(
401                r#"Json expect::iso_date_time() error at root:
402    ISO datetime '{now_str}' is in the future of now, expected between '1 minute' ago and now"#
403            )
404        );
405    }
406}
407
408#[cfg(test)]
409mod test_within_future {
410    use super::*;
411    use crate::expect;
412    use crate::expect_json_eq;
413    use pretty_assertions::assert_eq;
414    use serde_json::json;
415
416    #[test]
417    fn it_should_parse_iso_datetime_within_future_set() {
418        let now = Utc::now();
419        let now_str = (now + ChronoDuration::seconds(30)).to_rfc3339();
420        let left = json!(now_str);
421        let right = json!(expect::iso_date_time().within_future(StdDuration::from_secs(60)));
422
423        let output = expect_json_eq(&left, &right);
424        assert!(output.is_ok(), "assertion error: {output:#?}");
425    }
426
427    #[test]
428    fn it_should_not_parse_iso_datetime_within_past_too_far() {
429        let now = Utc::now();
430        let now_str = (now + ChronoDuration::seconds(90)).to_rfc3339();
431        let left = json!(now_str);
432        let right = json!(expect::iso_date_time().within_future(StdDuration::from_secs(60)));
433
434        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
435        assert_eq!(
436            output,
437            format!(
438                r#"Json expect::iso_date_time() error at root:
439    ISO datetime '{now_str}' is too far in the future, expected between now and '1 minute' in the future"#
440            )
441        );
442    }
443
444    #[test]
445    fn it_should_not_parse_iso_datetime_before_now() {
446        let now = Utc::now();
447        let now_str = (now - ChronoDuration::seconds(90)).to_rfc3339();
448        let left = json!(now_str);
449        let right = json!(expect::iso_date_time().within_future(StdDuration::from_secs(60)));
450
451        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
452        assert_eq!(
453            output,
454            format!(
455                r#"Json expect::iso_date_time() error at root:
456    ISO datetime '{now_str}' is in the past of now, expected between now and '1 minute' in the future"#
457            )
458        );
459    }
460
461    #[test]
462    fn it_should_pass_if_date_within_past_and_future() {
463        let now = Utc::now();
464        let now_str = now.to_rfc3339();
465        let left = json!(now_str);
466        let right = json!(
467            expect::iso_date_time()
468                .within_past(StdDuration::from_secs(60))
469                .within_future(StdDuration::from_secs(60))
470        );
471
472        let output = expect_json_eq(&left, &right);
473        assert!(output.is_ok(), "assertion error: {output:#?}");
474    }
475
476    #[test]
477    fn it_should_fail_if_date_behind_past_and_future() {
478        let now = Utc::now();
479        let now_str = (now - ChronoDuration::seconds(90)).to_rfc3339();
480        let left = json!(now_str);
481        let right = json!(
482            expect::iso_date_time()
483                .within_past(StdDuration::from_secs(60))
484                .within_future(StdDuration::from_secs(60))
485        );
486
487        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
488        assert_eq!(
489            output,
490            format!(
491                r#"Json expect::iso_date_time() error at root:
492    ISO datetime '{now_str}' is too far from the past, expected between '1 minute' ago and now"#
493            )
494        );
495    }
496
497    #[test]
498    fn it_should_fail_if_date_ahead_of_past_and_future() {
499        let now = Utc::now();
500        let now_str = (now + ChronoDuration::seconds(90)).to_rfc3339();
501        let left = json!(now_str);
502        let right = json!(
503            expect::iso_date_time()
504                .within_past(StdDuration::from_secs(60))
505                .within_future(StdDuration::from_secs(60))
506        );
507
508        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
509        assert_eq!(
510            output,
511            format!(
512                r#"Json expect::iso_date_time() error at root:
513    ISO datetime '{now_str}' is too far in the future, expected between now and '1 minute' in the future"#
514            )
515        );
516    }
517}