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