expect_json/expects/ops/
expect_iso_date_time.rs

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