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///
34/// use axum_test::expect_json::expect;
35///
36/// server.get(&"/latest-comment")
37///     .await
38///     .assert_json_contains(&json!({
39///         "comment": "My example comment",
40///         "created_at": expect::iso_date_time(),
41///
42///         // Expect it was updated in the last minute
43///         "updated_at": expect::iso_date_time()
44///             .within_past(Duration::from_secs(60)),
45///
46///         // Expect it also expires in the next minute
47///         "expires_at": expect::iso_date_time()
48///             .within_future(Duration::from_secs(60)),
49///
50///         // Users time could have any timezone
51///         "users_created_at": expect::iso_date_time()
52///             .allow_non_utc(),
53///     }));
54/// #
55/// # Ok(()) }
56/// ```
57///
58#[expect_op(internal, name = "iso_date_time")]
59#[derive(Debug, Clone, Default, PartialEq)]
60pub struct ExpectIsoDateTime {
61    is_utc_only: bool,
62    maybe_past_duration: Option<StdDuration>,
63    maybe_future_duration: Option<StdDuration>,
64}
65
66impl ExpectIsoDateTime {
67    pub(crate) fn new() -> Self {
68        Self {
69            is_utc_only: true,
70            maybe_past_duration: None,
71            maybe_future_duration: None,
72        }
73    }
74
75    ///
76    /// By default, `IsoDateTime` expects all date times to be in UTC.
77    ///
78    /// This method relaxes this constraint,
79    /// and will accept date times in any timezone.
80    ///
81    pub fn allow_non_utc(self) -> Self {
82        Self {
83            is_utc_only: false,
84            ..self
85        }
86    }
87
88    ///
89    /// Expects the date time to be within a past duration,
90    /// up to the current time.
91    ///
92    /// The constraint will fail when:
93    ///  - the datetime is further in the past than the given duration,
94    ///  - or ahead of the current time.
95    ///
96    pub fn within_past(self, duration: StdDuration) -> Self {
97        Self {
98            maybe_past_duration: Some(duration),
99            ..self
100        }
101    }
102
103    ///
104    /// Expects the date time to be within the current time,
105    /// and up to a future duration.
106    ///
107    /// The constraint will fail when:
108    ///  - the datetime is further ahead than the given duration,
109    ///  - or behind the current time.
110    ///
111    pub fn within_future(self, duration: StdDuration) -> Self {
112        Self {
113            maybe_future_duration: Some(duration),
114            ..self
115        }
116    }
117}
118
119impl ExpectOp for ExpectIsoDateTime {
120    fn on_string(&self, context: &mut Context, received: &str) -> ExpectOpResult<()> {
121        let date_time = DateTime::<FixedOffset>::parse_from_rfc3339(received).map_err(|error| {
122            let error_message = format!("failed to parse string '{received}' as iso date time");
123            ExpectOpError::custom_error(context, self, error_message, error)
124        })?;
125
126        if self.is_utc_only {
127            let is_date_time_utc = date_time.offset().fix().utc_minus_local() != 0;
128            if is_date_time_utc {
129                let error_message = format!(
130                    "ISO datetime '{received}' is using a non-UTC timezone, expected UTC only"
131                );
132                return Err(ExpectOpError::custom(context, self, error_message));
133            }
134        }
135
136        match (self.maybe_past_duration, self.maybe_future_duration) {
137            (None, None) => {}
138            (Some(past_duration), None) => {
139                let is_date_time_outside_past = date_time < Utc::now() - past_duration;
140                if is_date_time_outside_past {
141                    let duration =
142                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
143                    let error_message = format!("ISO datetime '{received}' is too far from the past, expected between '{duration}' ago and now");
144                    return Err(ExpectOpError::custom(context, self, error_message));
145                }
146
147                let is_date_time_ahead_of_now = date_time > Utc::now();
148                if is_date_time_ahead_of_now {
149                    let duration =
150                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
151                    let error_message = format!("ISO datetime '{received}' is in the future of now, expected between '{duration}' ago and now");
152                    return Err(ExpectOpError::custom(context, self, error_message));
153                }
154            }
155            (None, Some(future_duration)) => {
156                let is_date_time_outside_future = date_time > Utc::now() + future_duration;
157                if is_date_time_outside_future {
158                    let duration =
159                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
160                    let error_message = format!("ISO datetime '{received}' is too far in the future, expected between now and '{duration}' in the future");
161                    return Err(ExpectOpError::custom(context, self, error_message));
162                }
163
164                let is_date_time_behind_of_now = date_time < Utc::now();
165                if is_date_time_behind_of_now {
166                    let duration =
167                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
168                    let error_message = format!("ISO datetime '{received}' is in the past of now, expected between now and '{duration}' in the future");
169                    return Err(ExpectOpError::custom(context, self, error_message));
170                }
171            }
172            (Some(past_duration), Some(future_duration)) => {
173                let is_date_time_outside_past = date_time < Utc::now() - past_duration;
174                if is_date_time_outside_past {
175                    let duration =
176                        DurationFormatter::new(ChronoDuration::from_std(past_duration).unwrap());
177                    let error_message = format!("ISO datetime '{received}' is too far from the past, expected between '{duration}' ago and now");
178                    return Err(ExpectOpError::custom(context, self, error_message));
179                }
180
181                let is_date_time_outside_future = date_time > Utc::now() + future_duration;
182                if is_date_time_outside_future {
183                    let duration =
184                        DurationFormatter::new(ChronoDuration::from_std(future_duration).unwrap());
185                    let error_message = format!("ISO datetime '{received}' is too far in the future, expected between now and '{duration}' in the future");
186                    return Err(ExpectOpError::custom(context, self, error_message));
187                }
188            }
189        }
190
191        Ok(())
192    }
193
194    fn supported_types(&self) -> &'static [JsonType] {
195        &[JsonType::String]
196    }
197}
198
199#[cfg(test)]
200mod test_iso_date_time {
201    use crate::expect;
202    use crate::expect_json_eq;
203    use pretty_assertions::assert_eq;
204    use serde_json::json;
205
206    #[test]
207    fn it_should_parse_iso_datetime_with_utc_timezone() {
208        let left = json!("2024-01-15T13:45:30Z");
209        let right = json!(expect::iso_date_time());
210
211        let output = expect_json_eq(&left, &right);
212        assert!(output.is_ok(), "assertion error: {output:#?}");
213    }
214
215    #[test]
216    fn it_should_fail_to_parse_iso_datetime_with_non_utc_timezone() {
217        let left = json!("2024-01-15T13:45:30+01:00");
218        let right = json!(expect::iso_date_time());
219
220        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
221        assert_eq!(
222            output,
223            r#"Json expect::iso_date_time() error at root:
224    ISO datetime '2024-01-15T13:45:30+01:00' is using a non-UTC timezone, expected UTC only"#
225        );
226    }
227
228    #[test]
229    fn it_should_fail_to_parse_iso_datetime_without_timezone() {
230        let left = json!("2024-01-15T13:45:30");
231        let right = json!(expect::iso_date_time());
232
233        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
234        assert_eq!(
235            output,
236            r#"Json expect::iso_date_time() error at root:
237    failed to parse string '2024-01-15T13:45:30' as iso date time,
238    premature end of input"#
239        );
240    }
241}
242
243#[cfg(test)]
244mod test_utc_only {
245    use crate::expect;
246    use crate::expect_json_eq;
247    use serde_json::json;
248
249    #[test]
250    fn it_should_parse_iso_datetime_with_utc_timezone_when_set() {
251        let left = json!("2024-01-15T13:45:30Z");
252        let right = json!(expect::iso_date_time().allow_non_utc());
253
254        let output = expect_json_eq(&left, &right);
255        assert!(output.is_ok(), "assertion error: {output:#?}");
256    }
257
258    #[test]
259    fn it_should_parse_iso_datetime_with_non_utc_timezone_when_set() {
260        let left = json!("2024-01-15T13:45:30+01:00");
261        let right = json!(expect::iso_date_time().allow_non_utc());
262
263        let output = expect_json_eq(&left, &right);
264        assert!(output.is_ok(), "assertion error: {output:#?}");
265    }
266}
267
268#[cfg(test)]
269mod test_within_past {
270    use super::*;
271    use crate::expect;
272    use crate::expect_json_eq;
273    use pretty_assertions::assert_eq;
274    use serde_json::json;
275
276    #[test]
277    fn it_should_parse_iso_datetime_within_past_set() {
278        let now = Utc::now();
279        let now_str = (now - ChronoDuration::seconds(30)).to_rfc3339();
280        let left = json!(now_str);
281        let right = json!(expect::iso_date_time().within_past(StdDuration::from_secs(60)));
282
283        let output = expect_json_eq(&left, &right);
284        assert!(output.is_ok(), "assertion error: {output:#?}");
285    }
286
287    #[test]
288    fn it_should_not_parse_iso_datetime_within_past_too_far() {
289        let now = Utc::now();
290        let now_str = (now - ChronoDuration::seconds(90)).to_rfc3339();
291        let left = json!(now_str);
292        let right = json!(expect::iso_date_time().within_past(StdDuration::from_secs(60)));
293
294        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
295        assert_eq!(
296            output,
297            format!(
298                r#"Json expect::iso_date_time() error at root:
299    ISO datetime '{now_str}' is too far from the past, expected between '1 minute' ago and now"#
300            )
301        );
302    }
303
304    #[test]
305    fn it_should_not_parse_iso_datetime_ahead_of_now() {
306        let now = Utc::now();
307        let now_str = (now + ChronoDuration::seconds(90)).to_rfc3339();
308        let left = json!(now_str);
309        let right = json!(expect::iso_date_time().within_past(StdDuration::from_secs(60)));
310
311        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
312        assert_eq!(
313            output,
314            format!(
315                r#"Json expect::iso_date_time() error at root:
316    ISO datetime '{now_str}' is in the future of now, expected between '1 minute' ago and now"#
317            )
318        );
319    }
320}
321
322#[cfg(test)]
323mod test_within_future {
324    use super::*;
325    use crate::expect;
326    use crate::expect_json_eq;
327    use pretty_assertions::assert_eq;
328    use serde_json::json;
329
330    #[test]
331    fn it_should_parse_iso_datetime_within_future_set() {
332        let now = Utc::now();
333        let now_str = (now + ChronoDuration::seconds(30)).to_rfc3339();
334        let left = json!(now_str);
335        let right = json!(expect::iso_date_time().within_future(StdDuration::from_secs(60)));
336
337        let output = expect_json_eq(&left, &right);
338        assert!(output.is_ok(), "assertion error: {output:#?}");
339    }
340
341    #[test]
342    fn it_should_not_parse_iso_datetime_within_past_too_far() {
343        let now = Utc::now();
344        let now_str = (now + ChronoDuration::seconds(90)).to_rfc3339();
345        let left = json!(now_str);
346        let right = json!(expect::iso_date_time().within_future(StdDuration::from_secs(60)));
347
348        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
349        assert_eq!(
350            output,
351            format!(
352                r#"Json expect::iso_date_time() error at root:
353    ISO datetime '{now_str}' is too far in the future, expected between now and '1 minute' in the future"#
354            )
355        );
356    }
357
358    #[test]
359    fn it_should_not_parse_iso_datetime_before_now() {
360        let now = Utc::now();
361        let now_str = (now - ChronoDuration::seconds(90)).to_rfc3339();
362        let left = json!(now_str);
363        let right = json!(expect::iso_date_time().within_future(StdDuration::from_secs(60)));
364
365        let output = expect_json_eq(&left, &right).unwrap_err().to_string();
366        assert_eq!(
367            output,
368            format!(
369                r#"Json expect::iso_date_time() error at root:
370    ISO datetime '{now_str}' is in the past of now, expected between now and '1 minute' in the future"#
371            )
372        );
373    }
374}