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 expects a UTC timezone, and this can be disabled with [Self::allow_non_utc()].
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: true,
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()
92    ///             .allow_non_utc(),
93    ///     }));
94    /// #
95    /// # Ok(()) }
96    /// ```
97    ///
98    pub fn allow_non_utc(self) -> Self {
99        Self {
100            is_utc_only: false,
101            ..self
102        }
103    }
104
105    ///
106    /// Expects the date time to be within a past duration,
107    /// up to the current time.
108    ///
109    /// The constraint will fail when:
110    ///  - the datetime is further in the past than the given duration,
111    ///  - or ahead of the current time.
112    ///
113    /// ```rust
114    /// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
115    /// #
116    /// # use axum::Router;
117    /// # use axum::extract::Json;
118    /// # use axum::routing::get;
119    /// # use axum_test::TestServer;
120    /// # use serde_json::json;
121    /// #
122    /// # let server = TestServer::new(Router::new())?;
123    /// #
124    /// use std::time::Duration;
125    /// use axum_test::expect_json;
126    ///
127    /// let server = TestServer::new(Router::new())?;
128    ///
129    /// server.get(&"/latest-comment")
130    ///     .await
131    ///     .assert_json(&json!({
132    ///         "comment": "My example comment",
133    ///
134    ///         // Expect it was updated in the last minute
135    ///         "updated_at": expect_json::iso_date_time()
136    ///             .within_past(Duration::from_secs(60)),
137    ///     }));
138    /// #
139    /// # Ok(()) }
140    /// ```
141    ///
142    pub fn within_past(self, duration: StdDuration) -> Self {
143        Self {
144            maybe_past_duration: Some(duration),
145            ..self
146        }
147    }
148
149    ///
150    /// Expects the date time to be within the current time,
151    /// and up to a future duration.
152    ///
153    /// The constraint will fail when:
154    ///  - the datetime is further ahead than the given duration,
155    ///  - or behind the current time.
156    ///
157    /// ```rust
158    /// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
159    /// #
160    /// # use axum::Router;
161    /// # use axum::extract::Json;
162    /// # use axum::routing::get;
163    /// # use axum_test::TestServer;
164    /// # use serde_json::json;
165    /// #
166    /// #
167    /// use std::time::Duration;
168    /// use axum_test::expect_json;
169    ///
170    /// let server = TestServer::new(Router::new())?;
171    ///
172    /// server.get(&"/latest-comment")
173    ///     .await
174    ///     .assert_json(&json!({
175    ///         "comment": "My example comment",
176    ///
177    ///         // Expect it also expires in the next minute
178    ///         "expires_at": expect_json::iso_date_time()
179    ///             .within_future(Duration::from_secs(60)),
180    ///     }));
181    /// #
182    /// # Ok(()) }
183    /// ```
184    ///
185    pub fn within_future(self, duration: StdDuration) -> Self {
186        Self {
187            maybe_future_duration: Some(duration),
188            ..self
189        }
190    }
191}
192
193impl ExpectOp for ExpectIsoDateTime {
194    fn on_string(&self, context: &mut Context, received: &str) -> ExpectOpResult<()> {
195        let date_time = DateTime::<FixedOffset>::parse_from_rfc3339(received).map_err(|error| {
196            let error_message = format!("failed to parse string '{received}' as iso date time");
197            ExpectOpError::custom_error(self, context, error_message, error)
198        })?;
199
200        if self.is_utc_only {
201            let is_date_time_utc = date_time.offset().fix().utc_minus_local() != 0;
202            if is_date_time_utc {
203                let error_message = format!(
204                    "ISO datetime '{received}' is using a non-UTC timezone, expected UTC only"
205                );
206                return Err(ExpectOpError::custom(self, context, error_message));
207            }
208        }
209
210        match (self.maybe_past_duration, self.maybe_future_duration) {
211            (None, None) => {}
212            (Some(past_duration), None) => {
213                let is_date_time_outside_past = date_time < Utc::now() - past_duration;
214                if is_date_time_outside_past {
215                    let duration =
216                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
217                    let error_message = format!("ISO datetime '{received}' is too far from the past, expected between '{duration}' ago and now");
218                    return Err(ExpectOpError::custom(self, context, error_message));
219                }
220
221                let is_date_time_ahead_of_now = date_time > Utc::now();
222                if is_date_time_ahead_of_now {
223                    let duration =
224                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
225                    let error_message = format!("ISO datetime '{received}' is in the future of now, expected between '{duration}' ago and now");
226                    return Err(ExpectOpError::custom(self, context, error_message));
227                }
228            }
229            (None, Some(future_duration)) => {
230                let is_date_time_outside_future = date_time > Utc::now() + future_duration;
231                if is_date_time_outside_future {
232                    let duration =
233                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
234                    let error_message = format!("ISO datetime '{received}' is too far in the future, expected between now and '{duration}' in the future");
235                    return Err(ExpectOpError::custom(self, context, error_message));
236                }
237
238                let is_date_time_behind_of_now = date_time < Utc::now();
239                if is_date_time_behind_of_now {
240                    let duration =
241                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
242                    let error_message = format!("ISO datetime '{received}' is in the past of now, expected between now and '{duration}' in the future");
243                    return Err(ExpectOpError::custom(self, context, error_message));
244                }
245            }
246            (Some(past_duration), Some(future_duration)) => {
247                let is_date_time_outside_past = date_time < Utc::now() - past_duration;
248                if is_date_time_outside_past {
249                    let duration =
250                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
251                    let error_message = format!("ISO datetime '{received}' is too far from the past, expected between '{duration}' ago and now");
252                    return Err(ExpectOpError::custom(self, context, error_message));
253                }
254
255                let is_date_time_outside_future = date_time > Utc::now() + future_duration;
256                if is_date_time_outside_future {
257                    let duration =
258                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
259                    let error_message = format!("ISO datetime '{received}' is too far in the future, expected between now and '{duration}' in the future");
260                    return Err(ExpectOpError::custom(self, context, error_message));
261                }
262            }
263        }
264
265        Ok(())
266    }
267
268    fn debug_supported_types(&self) -> &'static [JsonType] {
269        &[JsonType::String]
270    }
271}
272
273#[cfg(test)]
274mod test_iso_date_time {
275    use crate::expect;
276    use crate::expect_json_eq;
277    use pretty_assertions::assert_eq;
278    use serde_json::json;
279
280    #[test]
281    fn it_should_parse_iso_datetime_with_utc_timezone() {
282        let left = json!("2024-01-15T13:45:30Z");
283        let right = json!(expect::iso_date_time());
284
285        let output = expect_json_eq(&left, &right);
286        assert!(output.is_ok(), "assertion error: {output:#?}");
287    }
288
289    #[test]
290    fn it_should_fail_to_parse_iso_datetime_with_non_utc_timezone() {
291        let left = json!("2024-01-15T13:45:30+01:00");
292        let right = json!(expect::iso_date_time());
293
294        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
295        assert_eq!(
296            output,
297            r#"Json expect::iso_date_time() error at root:
298    ISO datetime '2024-01-15T13:45:30+01:00' is using a non-UTC timezone, expected UTC only"#
299        );
300    }
301
302    #[test]
303    fn it_should_fail_to_parse_iso_datetime_without_timezone() {
304        let left = json!("2024-01-15T13:45:30");
305        let right = json!(expect::iso_date_time());
306
307        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
308        assert_eq!(
309            output,
310            r#"Json expect::iso_date_time() error at root:
311    failed to parse string '2024-01-15T13:45:30' as iso date time,
312    premature end of input"#
313        );
314    }
315}
316
317#[cfg(test)]
318mod test_utc_only {
319    use crate::expect;
320    use crate::expect_json_eq;
321    use serde_json::json;
322
323    #[test]
324    fn it_should_parse_iso_datetime_with_utc_timezone_when_set() {
325        let left = json!("2024-01-15T13:45:30Z");
326        let right = json!(expect::iso_date_time().allow_non_utc());
327
328        let output = expect_json_eq(&left, &right);
329        assert!(output.is_ok(), "assertion error: {output:#?}");
330    }
331
332    #[test]
333    fn it_should_parse_iso_datetime_with_non_utc_timezone_when_set() {
334        let left = json!("2024-01-15T13:45:30+01:00");
335        let right = json!(expect::iso_date_time().allow_non_utc());
336
337        let output = expect_json_eq(&left, &right);
338        assert!(output.is_ok(), "assertion error: {output:#?}");
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}