expect_json/expect/ops/
expect_iso_date_time.rs

1use crate::expect::ops::utils::DurationFormatter;
2use crate::expect_core::expect_op;
3use crate::expect_core::Context;
4use crate::expect_core::ExpectOp;
5use crate::expect_core::ExpectOpError;
6use crate::expect_core::ExpectOpResult;
7use crate::JsonType;
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!("ISO datetime '{received}' is too far from the past, expected between '{duration}' ago and now");
217                    return Err(ExpectOpError::custom(self, context, error_message));
218                }
219
220                let is_date_time_ahead_of_now = date_time > Utc::now();
221                if is_date_time_ahead_of_now {
222                    let duration =
223                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
224                    let error_message = format!("ISO datetime '{received}' is in the future of now, expected between '{duration}' ago and now");
225                    return Err(ExpectOpError::custom(self, context, error_message));
226                }
227            }
228            (None, Some(future_duration)) => {
229                let is_date_time_outside_future = date_time > Utc::now() + future_duration;
230                if is_date_time_outside_future {
231                    let duration =
232                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
233                    let error_message = format!("ISO datetime '{received}' is too far in the future, expected between now and '{duration}' in the future");
234                    return Err(ExpectOpError::custom(self, context, error_message));
235                }
236
237                let is_date_time_behind_of_now = date_time < Utc::now();
238                if is_date_time_behind_of_now {
239                    let duration =
240                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
241                    let error_message = format!("ISO datetime '{received}' is in the past of now, expected between now and '{duration}' in the future");
242                    return Err(ExpectOpError::custom(self, context, error_message));
243                }
244            }
245            (Some(past_duration), Some(future_duration)) => {
246                let is_date_time_outside_past = date_time < Utc::now() - past_duration;
247                if is_date_time_outside_past {
248                    let duration =
249                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
250                    let error_message = format!("ISO datetime '{received}' is too far from the past, expected between '{duration}' ago and now");
251                    return Err(ExpectOpError::custom(self, context, error_message));
252                }
253
254                let is_date_time_outside_future = date_time > Utc::now() + future_duration;
255                if is_date_time_outside_future {
256                    let duration =
257                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
258                    let error_message = format!("ISO datetime '{received}' is too far in the future, expected between now and '{duration}' in the future");
259                    return Err(ExpectOpError::custom(self, context, error_message));
260                }
261            }
262        }
263
264        Ok(())
265    }
266
267    fn debug_supported_types(&self) -> &'static [JsonType] {
268        &[JsonType::String]
269    }
270}
271
272#[cfg(test)]
273mod test_iso_date_time {
274    use crate::expect;
275    use crate::expect_json_eq;
276    use pretty_assertions::assert_eq;
277    use serde_json::json;
278
279    #[test]
280    fn it_should_parse_iso_datetime_with_utc_timezone() {
281        let left = json!("2024-01-15T13:45:30Z");
282        let right = json!(expect::iso_date_time());
283
284        let output = expect_json_eq(&left, &right);
285        assert!(output.is_ok(), "assertion error: {output:#?}");
286    }
287
288    #[test]
289    fn it_should_parse_iso_datetime_with_non_utc_timezone_by_default() {
290        let left = json!("2024-01-15T13:45:30+01:00");
291        let right = json!(expect::iso_date_time());
292
293        let output = expect_json_eq(&left, &right);
294        assert!(output.is_ok(), "assertion error: {output:#?}");
295    }
296
297    #[test]
298    fn it_should_fail_to_parse_iso_datetime_without_timezone() {
299        let left = json!("2024-01-15T13:45:30");
300        let right = json!(expect::iso_date_time());
301
302        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
303        assert_eq!(
304            output,
305            r#"Json expect::iso_date_time() error at root:
306    failed to parse string '2024-01-15T13:45:30' as iso date time,
307    premature end of input"#
308        );
309    }
310}
311
312#[cfg(test)]
313mod test_utc {
314    use crate::expect;
315    use crate::expect_json_eq;
316    use pretty_assertions::assert_eq;
317    use serde_json::json;
318
319    #[test]
320    fn it_should_parse_iso_datetime_with_utc_timezone_when_set() {
321        let left = json!("2024-01-15T13:45:30Z");
322        let right = json!(expect::iso_date_time().utc());
323
324        let output = expect_json_eq(&left, &right);
325        assert!(output.is_ok(), "assertion error: {output:#?}");
326    }
327
328    #[test]
329    fn it_should_not_parse_iso_datetime_with_non_utc_timezone_when_set() {
330        let left = json!("2024-01-15T13:45:30+01:00");
331        let right = json!(expect::iso_date_time().utc());
332
333        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
334        assert_eq!(
335            output,
336            "Json expect::iso_date_time() error at root:
337    ISO datetime '2024-01-15T13:45:30+01:00' is using a non-UTC timezone, expected UTC only"
338        );
339    }
340}
341
342#[cfg(test)]
343mod test_within_past {
344    use super::*;
345    use crate::expect;
346    use crate::expect_json_eq;
347    use pretty_assertions::assert_eq;
348    use serde_json::json;
349
350    #[test]
351    fn it_should_parse_iso_datetime_within_past_set() {
352        let now = Utc::now();
353        let now_str = (now - ChronoDuration::seconds(30)).to_rfc3339();
354        let left = json!(now_str);
355        let right = json!(expect::iso_date_time().within_past(StdDuration::from_secs(60)));
356
357        let output = expect_json_eq(&left, &right);
358        assert!(output.is_ok(), "assertion error: {output:#?}");
359    }
360
361    #[test]
362    fn it_should_not_parse_iso_datetime_within_past_too_far() {
363        let now = Utc::now();
364        let now_str = (now - ChronoDuration::seconds(90)).to_rfc3339();
365        let left = json!(now_str);
366        let right = json!(expect::iso_date_time().within_past(StdDuration::from_secs(60)));
367
368        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
369        assert_eq!(
370            output,
371            format!(
372                r#"Json expect::iso_date_time() error at root:
373    ISO datetime '{now_str}' is too far from the past, expected between '1 minute' ago and now"#
374            )
375        );
376    }
377
378    #[test]
379    fn it_should_not_parse_iso_datetime_ahead_of_now() {
380        let now = Utc::now();
381        let now_str = (now + ChronoDuration::seconds(90)).to_rfc3339();
382        let left = json!(now_str);
383        let right = json!(expect::iso_date_time().within_past(StdDuration::from_secs(60)));
384
385        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
386        assert_eq!(
387            output,
388            format!(
389                r#"Json expect::iso_date_time() error at root:
390    ISO datetime '{now_str}' is in the future of now, expected between '1 minute' ago and now"#
391            )
392        );
393    }
394}
395
396#[cfg(test)]
397mod test_within_future {
398    use super::*;
399    use crate::expect;
400    use crate::expect_json_eq;
401    use pretty_assertions::assert_eq;
402    use serde_json::json;
403
404    #[test]
405    fn it_should_parse_iso_datetime_within_future_set() {
406        let now = Utc::now();
407        let now_str = (now + ChronoDuration::seconds(30)).to_rfc3339();
408        let left = json!(now_str);
409        let right = json!(expect::iso_date_time().within_future(StdDuration::from_secs(60)));
410
411        let output = expect_json_eq(&left, &right);
412        assert!(output.is_ok(), "assertion error: {output:#?}");
413    }
414
415    #[test]
416    fn it_should_not_parse_iso_datetime_within_past_too_far() {
417        let now = Utc::now();
418        let now_str = (now + ChronoDuration::seconds(90)).to_rfc3339();
419        let left = json!(now_str);
420        let right = json!(expect::iso_date_time().within_future(StdDuration::from_secs(60)));
421
422        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
423        assert_eq!(
424            output,
425            format!(
426                r#"Json expect::iso_date_time() error at root:
427    ISO datetime '{now_str}' is too far in the future, expected between now and '1 minute' in the future"#
428            )
429        );
430    }
431
432    #[test]
433    fn it_should_not_parse_iso_datetime_before_now() {
434        let now = Utc::now();
435        let now_str = (now - ChronoDuration::seconds(90)).to_rfc3339();
436        let left = json!(now_str);
437        let right = json!(expect::iso_date_time().within_future(StdDuration::from_secs(60)));
438
439        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
440        assert_eq!(
441            output,
442            format!(
443                r#"Json expect::iso_date_time() error at root:
444    ISO datetime '{now_str}' is in the past of now, expected between now and '1 minute' in the future"#
445            )
446        );
447    }
448
449    #[test]
450    fn it_should_pass_if_date_within_past_and_future() {
451        let now = Utc::now();
452        let now_str = now.to_rfc3339();
453        let left = json!(now_str);
454        let right = json!(expect::iso_date_time()
455            .within_past(StdDuration::from_secs(60))
456            .within_future(StdDuration::from_secs(60)));
457
458        let output = expect_json_eq(&left, &right);
459        assert!(output.is_ok(), "assertion error: {output:#?}");
460    }
461
462    #[test]
463    fn it_should_fail_if_date_behind_past_and_future() {
464        let now = Utc::now();
465        let now_str = (now - ChronoDuration::seconds(90)).to_rfc3339();
466        let left = json!(now_str);
467        let right = json!(expect::iso_date_time()
468            .within_past(StdDuration::from_secs(60))
469            .within_future(StdDuration::from_secs(60)));
470
471        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
472        assert_eq!(
473            output,
474            format!(
475                r#"Json expect::iso_date_time() error at root:
476    ISO datetime '{now_str}' is too far from the past, expected between '1 minute' ago and now"#
477            )
478        );
479    }
480
481    #[test]
482    fn it_should_fail_if_date_ahead_of_past_and_future() {
483        let now = Utc::now();
484        let now_str = (now + ChronoDuration::seconds(90)).to_rfc3339();
485        let left = json!(now_str);
486        let right = json!(expect::iso_date_time()
487            .within_past(StdDuration::from_secs(60))
488            .within_future(StdDuration::from_secs(60)));
489
490        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
491        assert_eq!(
492            output,
493            format!(
494                r#"Json expect::iso_date_time() error at root:
495    ISO datetime '{now_str}' is too far in the future, expected between now and '1 minute' in the future"#
496            )
497        );
498    }
499}