Skip to main content

jmap_types/
id.rs

1//! RFC 8620 §1.2/§1.4 opaque string newtypes: [`Id`], [`UTCDate`], [`Date`], [`State`].
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// Error returned by the fallible constructors [`Id::new_validated`],
7/// [`UTCDate::new_validated`], and [`State::new_validated`].
8///
9/// The message is reachable only through [`fmt::Display`]; the inner
10/// field is private, like the other newtypes in this module:
11///
12/// ```compile_fail
13/// let err = jmap_types::Id::new_validated("").unwrap_err();
14/// let _ = err.0;
15/// ```
16#[non_exhaustive]
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ValidationError(String);
19
20impl fmt::Display for ValidationError {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        f.write_str(&self.0)
23    }
24}
25
26impl std::error::Error for ValidationError {}
27
28/// Opaque non-empty server-assigned identifier (RFC 8620 §1.2).
29///
30/// Character set: URL-safe base64 alphabet (A-Za-z0-9, `-`, `_`), max 255 octets.
31/// Clients MUST treat Id values as opaque strings — no parsing of structure.
32///
33/// # Deserialization is validated
34///
35/// Deserializing an `Id` applies the same check as [`Id::new_validated`],
36/// so a wire value outside the RFC 8620 §1.2 syntax is a deserialization
37/// error rather than an unchecked `Id`:
38///
39/// ```
40/// use jmap_types::Id;
41/// assert!(serde_json::from_str::<Id>("\"abc-_123\"").is_ok());
42/// assert!(serde_json::from_str::<Id>("\"has space\"").is_err());
43/// ```
44///
45/// [`Id::from`] remains an unchecked constructor for values the caller
46/// already trusts.
47// #[non_exhaustive] prevents callers from pattern-matching the inner field
48// (e.g. `let Id(s) = id;`), preserving semver freedom to add fields later.
49#[non_exhaustive]
50#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
51#[serde(transparent)]
52pub struct Id(String);
53
54// A manual impl rather than `#[serde(try_from = "String")]`: the existing
55// `From<String>` makes the blanket `TryFrom<String>` infallible, so a
56// fallible `TryFrom<String>` cannot be added (see the note above
57// `validate_id`).
58impl<'de> Deserialize<'de> for Id {
59    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
60        let s = String::deserialize(deserializer)?;
61        Self::new_validated(s).map_err(serde::de::Error::custom)
62    }
63}
64
65/// RFC 3339 UTC timestamp string (RFC 8620 §1.4).
66///
67/// Format: `YYYY-MM-DDTHH:MM:SS[.fff…]Z` — time-offset MUST be `Z`, letters
68/// uppercase, fractional seconds omitted if zero. Examples:
69/// `"2014-10-30T06:12:00Z"`, `"2014-10-30T06:12:00.123Z"`.
70///
71/// # Deserialization is NOT validated
72///
73/// `UTCDate` is `#[serde(transparent)]` over `String`. Any string that
74/// deserializes into a `String` deserializes into a `UTCDate` — including
75/// `"not-a-date"`, `"2024-01-19T18:00:00"` (no `Z` suffix), or any other
76/// shape that violates RFC 8620 §1.4. The newtype carries the
77/// **type-level intent** of "RFC 8620 UTC timestamp" but does NOT enforce
78/// it at the wire boundary.
79///
80/// Use [`UTCDate::new_validated`] when constructing from untrusted input,
81/// or call [`UTCDate::to_epoch_seconds`] when consuming a deserialized
82/// value: the latter re-validates structural format AND semantic ranges
83/// (month, day, hour, minute, second) and returns [`ValidationError`] on
84/// any deviation. Treat any field of type `Option<UTCDate>` arriving
85/// from a peer as "RFC 8620 timestamp by convention, not by contract".
86// #[non_exhaustive] prevents callers from pattern-matching the inner field,
87// preserving semver freedom to add fields later.
88#[non_exhaustive]
89#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
90#[serde(transparent)]
91pub struct UTCDate(String);
92
93/// RFC 3339 date-time string with any timezone offset (RFC 8620 §1.4).
94///
95/// Format: `YYYY-MM-DDTHH:MM:SS±HH:MM` or `Z` suffix — any valid RFC 3339 offset,
96/// letters uppercase, fractional seconds omitted if zero.
97/// Example: `"2014-10-30T14:12:00+08:00"`.
98///
99/// Distinct from [`UTCDate`], which requires the time-offset to be `Z`.
100/// Use `Date` for fields derived from RFC 5322 email headers (e.g. `sentAt`),
101/// which commonly carry non-UTC offsets.
102///
103/// # Deserialization is NOT validated
104///
105/// `Date` is `#[serde(transparent)]` over `String`. Any string that
106/// deserializes into a `String` deserializes into a `Date`, including
107/// values that violate RFC 8620 §1.4 / RFC 3339. The newtype carries
108/// the **type-level intent** but does NOT enforce it at the wire
109/// boundary. Treat any field of type `Option<Date>` arriving from a
110/// peer as "RFC 8620 timestamp by convention, not by contract".
111// #[non_exhaustive] prevents callers from pattern-matching the inner field,
112// preserving semver freedom to add fields later.
113#[non_exhaustive]
114#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(transparent)]
116pub struct Date(String);
117
118/// Opaque server state token (RFC 8620 §1.2).
119///
120/// Returned by `/get` and `/changes` methods. Clients echo it back in
121/// `sinceState` / `ifInState` parameters. Treat as opaque — no structure assumed.
122///
123/// # Migrating from `String`-typed code
124///
125/// Earlier revisions of this crate exposed state fields as
126/// `pub session_state: String` on [`crate::JmapResponse`] and similar
127/// shapes. The current revision uses the `State` newtype for type
128/// safety. The wire format is unchanged (`State` is
129/// `#[serde(transparent)]` over `String`). The Rust API conversions
130/// available to callers migrating from `String`:
131///
132/// - Read as `&str`: `state.as_ref()` (via `AsRef<str>`) or
133///   `format!("{state}")` (via `Display`).
134/// - Compare to a literal: `state == "abc"` (via `PartialEq<str>`).
135/// - Move into an owned `String`: `state.into_inner()`.
136/// - Borrow as `&String`: not directly available; use `as_ref()` to
137///   get `&str` instead.
138/// - Build from `&str` or `String`: `State::from("abc")` or
139///   `State::from(my_string)` (via `From<&str>` / `From<String>`).
140///
141/// Callers passing a `State` to a function that takes `String` should
142/// either change the signature to `impl AsRef<str>` or call
143/// `state.into_inner()` at the boundary. Callers keying a
144/// `HashMap<String, _>` by state token can either rekey by
145/// `HashMap<State, _>` (`State` implements `Hash + Eq`) or call
146/// `state.into_inner()` at insertion / lookup time.
147// #[non_exhaustive] prevents callers from pattern-matching the inner field,
148// preserving semver freedom to add fields later.
149#[non_exhaustive]
150#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
151#[serde(transparent)]
152pub struct State(String);
153
154/// Generates `Display`, `From<String>`, `From<&str>`, `AsRef<str>`,
155/// `PartialEq<str>`, `PartialEq<&str>`, and `into_inner` for a transparent
156/// `String` newtype.
157macro_rules! impl_string_newtype {
158    ($T:ident) => {
159        impl fmt::Display for $T {
160            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161                f.write_str(&self.0)
162            }
163        }
164        impl From<String> for $T {
165            fn from(s: String) -> Self {
166                Self(s)
167            }
168        }
169        impl From<&str> for $T {
170            fn from(s: &str) -> Self {
171                Self(s.to_owned())
172            }
173        }
174        impl AsRef<str> for $T {
175            fn as_ref(&self) -> &str {
176                &self.0
177            }
178        }
179        impl PartialEq<str> for $T {
180            fn eq(&self, other: &str) -> bool {
181                self.0 == other
182            }
183        }
184        impl PartialEq<&str> for $T {
185            fn eq(&self, other: &&str) -> bool {
186                self.0 == *other
187            }
188        }
189        impl std::borrow::Borrow<str> for $T {
190            fn borrow(&self) -> &str {
191                &self.0
192            }
193        }
194        impl $T {
195            /// Consumes the value and returns the inner `String`.
196            pub fn into_inner(self) -> String {
197                self.0
198            }
199        }
200    };
201}
202
203impl_string_newtype!(Id);
204impl_string_newtype!(UTCDate);
205impl_string_newtype!(Date);
206impl_string_newtype!(State);
207
208// ---------------------------------------------------------------------------
209// Fallible constructors — validate RFC 8620 constraints at the boundary.
210//
211// These are named constructors (not TryFrom impls) because Id/UTCDate/State
212// already implement From<String> and From<&str>.  Rust's blanket impl
213// `impl<T,U> TryFrom<U> where U: Into<T>` would make TryFrom<String>
214// infallible (Error = Infallible) via the existing From impl, making it
215// impossible to add a second, fallible TryFrom<String>.  Named constructors
216// achieve the same goal without the conflict.
217// ---------------------------------------------------------------------------
218
219/// Validate an [`Id`] string per RFC 8620 §1.2.
220///
221/// RFC 8620 §1.2 requires Ids to use the "URL and Filename Safe" base64
222/// alphabet defined in RFC 4648 §5, excluding the pad character `=`.
223/// That is: ASCII alphanumeric characters (`A-Z`, `a-z`, `0-9`), hyphen
224/// (`-`), and underscore (`_`). The string must also be non-empty and
225/// at most 255 bytes.
226fn validate_id(s: &str) -> Result<(), ValidationError> {
227    if s.is_empty() {
228        return Err(ValidationError("Id must not be empty".into()));
229    }
230    if s.len() > 255 {
231        return Err(ValidationError(format!(
232            "Id exceeds 255 bytes (got {})",
233            s.len()
234        )));
235    }
236    for ch in s.chars() {
237        // RFC 4648 §5 URL-safe base64 alphabet, minus the pad `=`:
238        // A-Z, a-z, 0-9, '-', '_'.
239        if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
240            return Err(ValidationError(format!(
241                "Id contains invalid character {:?} (U+{:04X})",
242                ch, ch as u32
243            )));
244        }
245    }
246    Ok(())
247}
248
249/// Validate a [`UTCDate`] string per RFC 8620 §1.4.
250///
251/// Validation has two layers:
252///
253/// 1. **Shape**: the `YYYY-MM-DDTHH:MM:SS` layout with ASCII digits in
254///    every numeric position, an optional RFC 3339 `time-secfrac`
255///    (`.` followed by one or more digits, not all zero, because RFC 8620
256///    §1.4 says it "MUST always be omitted if zero"), then `Z`.
257/// 2. **Values**: month in `1..=12`, day in `1..=days_in_month(year,
258///    month)` (proleptic Gregorian, exact leap-year rules), hour in
259///    `0..=23`, minute in `0..=59`, second in `0..=59` (RFC 8620 §1.4
260///    does not permit leap seconds even though RFC 3339 §5.6 does).
261///
262/// No external crate needed.
263fn validate_utcdate(s: &str) -> Result<(), ValidationError> {
264    if s.len() < 20 {
265        return Err(ValidationError(format!(
266            "UTCDate must be at least 20 characters (YYYY-MM-DDTHH:MM:SSZ), got {:?}",
267            s
268        )));
269    }
270    let b = s.as_bytes();
271    // Tail after the seconds: `Z`, or `.` + non-zero digit run + `Z`.
272    let tail_ok = match &b[19..] {
273        [b'Z'] => true,
274        [b'.', frac @ .., b'Z'] => {
275            !frac.is_empty()
276                && frac.iter().all(u8::is_ascii_digit)
277                && frac.iter().any(|&d| d != b'0')
278        }
279        _ => false,
280    };
281    // Fixed separators: dashes, T, colons.
282    if !tail_ok || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
283        return Err(ValidationError(format!(
284            "UTCDate has wrong structure, expected YYYY-MM-DDTHH:MM:SS[.fff]Z, got {:?}",
285            s
286        )));
287    }
288    // Digit positions: 0-3 (year), 5-6 (month), 8-9 (day),
289    //                  11-12 (hour), 14-15 (min), 17-18 (sec).
290    for &pos in &[0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18] {
291        if !b[pos].is_ascii_digit() {
292            return Err(ValidationError(format!(
293                "UTCDate position {} is not a digit in {:?}",
294                pos, s
295            )));
296        }
297    }
298
299    // Value-range checks. Shape is now confirmed, so digit positions
300    // are guaranteed ASCII digits and the parses are infallible.
301    let parse = |start: usize, end: usize| -> i64 {
302        let mut n: i64 = 0;
303        for &byte in &b[start..end] {
304            n = n * 10 + (byte - b'0') as i64;
305        }
306        n
307    };
308    let year = parse(0, 4);
309    let month = parse(5, 7) as u32;
310    let day = parse(8, 10) as u32;
311    let hour = parse(11, 13) as u32;
312    let minute = parse(14, 16) as u32;
313    let second = parse(17, 19) as u32;
314
315    if !(1..=12).contains(&month) {
316        return Err(ValidationError(format!(
317            "UTCDate month must be 1..=12, got {month} in {s:?}"
318        )));
319    }
320    let max_day = days_in_month(year, month);
321    if !(1..=max_day).contains(&day) {
322        return Err(ValidationError(format!(
323            "UTCDate day must be 1..={max_day} for {year:04}-{month:02}, got {day} in {s:?}"
324        )));
325    }
326    if hour > 23 {
327        return Err(ValidationError(format!(
328            "UTCDate hour must be 0..=23, got {hour} in {s:?}"
329        )));
330    }
331    if minute > 59 {
332        return Err(ValidationError(format!(
333            "UTCDate minute must be 0..=59, got {minute} in {s:?}"
334        )));
335    }
336    // No leap seconds in RFC 8620 §1.4 (it specifies seconds 0..=59).
337    if second > 59 {
338        return Err(ValidationError(format!(
339            "UTCDate second must be 0..=59, got {second} in {s:?}"
340        )));
341    }
342    Ok(())
343}
344
345/// Validate a [`State`] string: must be non-empty.
346///
347/// RFC 8620 §1.2 does not restrict the character set for State beyond
348/// requiring it to be non-empty.
349fn validate_state(s: &str) -> Result<(), ValidationError> {
350    if s.is_empty() {
351        return Err(ValidationError("State must not be empty".into()));
352    }
353    Ok(())
354}
355
356impl Id {
357    /// Construct an [`Id`] with RFC 8620 §1.2 syntax validation.
358    ///
359    /// RFC 8620 §1.2 restricts Ids to the URL-safe base64 alphabet defined
360    /// in RFC 4648 §5, excluding the pad character `=`. The permitted
361    /// characters are therefore the ASCII alphanumerics (`A-Z`, `a-z`,
362    /// `0-9`), hyphen (`-`), and underscore (`_`). The string must also
363    /// be non-empty and at most 255 bytes.
364    ///
365    /// # Errors
366    ///
367    /// Returns [`ValidationError`] when the input does not satisfy
368    /// RFC 8620 §1.2. The error description contains a category keyword
369    /// that callers can match on if they need finer-grained handling:
370    ///
371    /// - `"empty"` — the input was an empty string;
372    /// - `"exceeds 255 bytes"` — the input was longer than the spec
373    ///   maximum;
374    /// - `"invalid character"` — the input contained at least one
375    ///   character outside `A-Z`, `a-z`, `0-9`, `-`, `_`.
376    ///
377    /// Use [`Id::from`] when the value is known to be valid (e.g. a string
378    /// received from a JMAP server response).
379    pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
380        let s = s.into();
381        validate_id(&s)?;
382        Ok(Self(s))
383    }
384}
385
386impl UTCDate {
387    /// Construct a [`UTCDate`] with RFC 8620 §1.4 validation.
388    ///
389    /// Validation has two layers:
390    ///
391    /// 1. **Shape**: the `YYYY-MM-DDTHH:MM:SS` layout with ASCII
392    ///    digits in every numeric position, an optional fractional
393    ///    second (`.` and one or more digits, rejected when all zero
394    ///    because RFC 8620 §1.4 requires a zero `time-secfrac` to be
395    ///    omitted), then `Z`.
396    /// 2. **Values**: month `1..=12`; day `1..=days_in_month(year,
397    ///    month)` with proleptic Gregorian leap-year rules so e.g.
398    ///    `2024-02-29` is accepted but `2023-02-29` and `2024-02-30`
399    ///    are rejected; hour `0..=23`; minute `0..=59`; second
400    ///    `0..=59` (RFC 8620 §1.4 does not permit leap seconds even
401    ///    though RFC 3339 §5.6 does).
402    ///
403    /// # Errors
404    ///
405    /// Returns [`ValidationError`] when the input does not satisfy
406    /// RFC 8620 §1.4. The error description contains a substring that
407    /// callers can match on if they need finer-grained handling:
408    ///
409    /// - `"at least 20 characters"` — too short;
410    /// - `"wrong structure"` — separators or `Z` suffix missing /
411    ///   misplaced, or a fractional second that is empty, non-numeric
412    ///   or zero;
413    /// - `"is not a digit"` — non-digit at a numeric position;
414    /// - `"month must be"` — month outside `1..=12`;
415    /// - `"day must be"` — day outside `1..=days_in_month(year,
416    ///   month)` (catches both out-of-range day numbers like `32` and
417    ///   non-existent dates like Feb 30 or Feb 29 in a non-leap year);
418    /// - `"hour must be"`, `"minute must be"`, `"second must be"` —
419    ///   the corresponding time component is out of range.
420    ///
421    /// Use [`UTCDate::from`] when the value is known to be valid.
422    pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
423        let s = s.into();
424        validate_utcdate(&s)?;
425        Ok(Self(s))
426    }
427
428    /// Convert this [`UTCDate`] to seconds since the Unix epoch
429    /// (`1970-01-01T00:00:00Z`).
430    ///
431    /// Re-validates the structural RFC 8620 §1.4 format (the value may have
432    /// been constructed via [`UTCDate::from`] without validation) and also
433    /// validates semantic ranges: month `1..=12`, day `1..=days_in_month`,
434    /// hour `0..=23`, minute `0..=59`, second `0..=59` (no leap seconds).
435    /// Returns [`ValidationError`] on any validation failure.
436    ///
437    /// Negative values are returned for dates before `1970-01-01T00:00:00Z`.
438    /// A fractional second is truncated toward the earlier whole second.
439    ///
440    /// Uses the proleptic Gregorian calendar via Hinnant's `days_from_civil`
441    /// algorithm, which is exact-integer and handles leap years and century
442    /// rules correctly. No external dependencies.
443    ///
444    /// # Examples
445    ///
446    /// ```
447    /// use jmap_types::UTCDate;
448    /// let d = UTCDate::new_validated("1970-01-01T00:00:00Z").unwrap();
449    /// assert_eq!(d.to_epoch_seconds().unwrap(), 0);
450    /// let rfc = UTCDate::new_validated("2014-10-30T06:12:00Z").unwrap();
451    /// assert_eq!(rfc.to_epoch_seconds().unwrap(), 1_414_649_520);
452    /// ```
453    pub fn to_epoch_seconds(&self) -> Result<i64, ValidationError> {
454        utcdate_to_epoch_seconds(&self.0)
455    }
456}
457
458/// Number of days in `month` of `year`, accounting for proleptic Gregorian
459/// leap-year rules (year divisible by 4, except centuries not divisible by 400).
460fn days_in_month(year: i64, month: u32) -> u32 {
461    match month {
462        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
463        4 | 6 | 9 | 11 => 30,
464        2 => {
465            if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
466                29
467            } else {
468                28
469            }
470        }
471        _ => 0, // unreachable when month range is validated by caller
472    }
473}
474
475/// Hinnant's `days_from_civil` algorithm: number of days from
476/// `1970-01-01` (positive) or to `1970-01-01` (negative) for the proleptic
477/// Gregorian date `(y, m, d)`. Source: Howard Hinnant, "chrono-Compatible
478/// Low-Level Date Algorithms", §6.
479///
480/// Preconditions (caller-validated): `m` in `1..=12`, `d` in
481/// `1..=days_in_month(y, m)`. Years are unbounded `i64`.
482fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
483    let y = if m <= 2 { y - 1 } else { y };
484    let era = if y >= 0 { y } else { y - 399 } / 400;
485    let yoe = y - era * 400; // [0, 399]
486    let m = m as i64;
487    let d = d as i64;
488    let mp = if m > 2 { m - 3 } else { m + 9 }; // [0, 11]
489    let doy = (153 * mp + 2) / 5 + d - 1; // [0, 365]
490    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
491    era * 146097 + doe - 719_468
492}
493
494/// Internal helper: convert a `YYYY-MM-DDTHH:MM:SS[.fff]Z` string to epoch
495/// seconds, truncating any fractional second.
496///
497/// `validate_utcdate` performs both shape and value-range validation;
498/// after it returns Ok, the per-position digit parses are infallible
499/// and the parsed values are within their RFC 8620 §1.4 ranges.
500fn utcdate_to_epoch_seconds(s: &str) -> Result<i64, ValidationError> {
501    validate_utcdate(s)?;
502    let parse = |start: usize, end: usize| -> i64 {
503        let mut n: i64 = 0;
504        for &b in &s.as_bytes()[start..end] {
505            n = n * 10 + (b - b'0') as i64;
506        }
507        n
508    };
509    let year = parse(0, 4);
510    let month = parse(5, 7) as u32;
511    let day = parse(8, 10) as u32;
512    let hour = parse(11, 13) as u32;
513    let minute = parse(14, 16) as u32;
514    let second = parse(17, 19) as u32;
515
516    let days = days_from_civil(year, month, day);
517    Ok(days * 86_400 + hour as i64 * 3_600 + minute as i64 * 60 + second as i64)
518}
519
520impl State {
521    /// Construct a [`State`] with RFC 8620 §1.2 validation.
522    ///
523    /// Rejects empty strings. RFC 8620 §1.2 requires State to be non-empty;
524    /// no character-set restriction is imposed.
525    ///
526    /// Use [`State::from`] when the value is known to be valid.
527    pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
528        let s = s.into();
529        validate_state(&s)?;
530        Ok(Self(s))
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    // Oracle: RFC 8620 §1.2 — Id is a plain JSON string, not a wrapped object.
539    #[test]
540    fn id_serializes_as_plain_string() {
541        let id = Id("abc123".to_owned());
542        let json = serde_json::to_string(&id).expect("serialize Id");
543        assert_eq!(json, "\"abc123\"");
544    }
545
546    // Oracle: RFC 8620 §1.2 — Id round-trips through JSON.
547    #[test]
548    fn id_deserializes_from_plain_string() {
549        let id: Id = serde_json::from_str("\"abc123\"").expect("deserialize Id");
550        assert_eq!(id.as_ref(), "abc123");
551    }
552
553    // Oracle: RFC 8620 §1.2 — "MUST be at least 1 character in length and
554    // maximum 255 octets in size" and "MUST only contain characters from the
555    // 'URL and Filename Safe' base64 alphabet ... excluding the pad
556    // character (=)". Hand-written JSON strings outside that rule must fail
557    // to deserialize.
558    #[test]
559    fn id_deserialize_rejects_non_conforming_strings() {
560        let too_long = format!("\"{}\"", "a".repeat(256));
561        for json in [
562            "\"\"",
563            "\"has space\"",
564            "\"#creation\"",
565            "\"a=\"",
566            "\"a/b\"",
567            "\"a.b\"",
568            too_long.as_str(),
569        ] {
570            assert!(
571                serde_json::from_str::<Id>(json).is_err(),
572                "{json} must be rejected"
573            );
574        }
575    }
576
577    // Oracle: RFC 8620 §1.2 — the full alphabet and the 255-octet maximum
578    // are accepted.
579    #[test]
580    fn id_deserialize_accepts_conforming_strings() {
581        let max = format!("\"{}\"", "Z".repeat(255));
582        for json in ["\"AZaz09-_\"", max.as_str()] {
583            assert!(
584                serde_json::from_str::<Id>(json).is_ok(),
585                "{json} must be accepted"
586            );
587        }
588    }
589
590    // Oracle: RFC 8620 §1.2 — Ids used as JSON object keys (e.g. /set
591    // "update") are validated the same way.
592    #[test]
593    fn id_deserialize_validates_map_keys() {
594        use std::collections::HashMap;
595        let ok: HashMap<Id, u8> = serde_json::from_str(r#"{"m1":1}"#).expect("valid key");
596        assert_eq!(ok.get("m1"), Some(&1));
597        assert!(serde_json::from_str::<HashMap<Id, u8>>(r#"{"bad key":1}"#).is_err());
598    }
599
600    // Oracle: RFC 8620 §1.4 example — "2014-10-30T06:12:00Z".
601    #[test]
602    fn utcdate_serializes_as_plain_string() {
603        let d = UTCDate("2014-10-30T06:12:00Z".to_owned());
604        let json = serde_json::to_string(&d).expect("serialize UTCDate");
605        assert_eq!(json, "\"2014-10-30T06:12:00Z\"");
606    }
607
608    // Oracle: RFC 8620 §3.4.1 fixture — sessionState value is "75128aab4b1b".
609    #[test]
610    fn state_serializes_as_plain_string() {
611        let s = State("75128aab4b1b".to_owned());
612        let json = serde_json::to_string(&s).expect("serialize State");
613        assert_eq!(json, "\"75128aab4b1b\"");
614    }
615
616    // Oracle: From<&str> trait contract.
617    #[test]
618    fn id_from_str() {
619        let id = Id::from("hello");
620        assert_eq!(id.as_ref(), "hello");
621    }
622
623    // Oracle: Display delegates to inner String.
624    #[test]
625    fn id_display() {
626        let id = Id("display-test".to_owned());
627        assert_eq!(id.to_string(), "display-test");
628    }
629
630    // Oracle: AsRef<str> returns the inner string.
631    #[test]
632    fn id_as_ref_str() {
633        let id = Id("ref-test".to_owned());
634        assert_eq!(id.as_ref(), "ref-test");
635    }
636
637    // Oracle: RFC 8620 §3.4.1 — State in sessionState field round-trips correctly.
638    #[test]
639    fn state_round_trip() {
640        let s = State("75128aab4b1b".to_owned());
641        let json = serde_json::to_string(&s).expect("serialize");
642        let s2: State = serde_json::from_str(&json).expect("deserialize");
643        assert_eq!(s, s2);
644    }
645
646    // Oracle: RFC 8620 §1.4 example — Date allows non-UTC offsets, unlike UTCDate.
647    #[test]
648    fn date_accepts_non_utc_offset() {
649        let d = Date("2014-10-30T14:12:00+08:00".to_owned());
650        let json = serde_json::to_string(&d).expect("serialize Date");
651        assert_eq!(json, "\"2014-10-30T14:12:00+08:00\"");
652        let d2: Date = serde_json::from_str(&json).expect("deserialize Date");
653        assert_eq!(d, d2);
654    }
655
656    // -----------------------------------------------------------------------
657    // new_validated / ValidationError tests
658    // Oracle for all: RFC 8620 §1.2 (Id, State) and §1.4 (UTCDate).
659    // -----------------------------------------------------------------------
660
661    /// Oracle: RFC 8620 §1.2 — Id must not be empty.
662    #[test]
663    fn id_new_validated_empty_fails() {
664        let err = Id::new_validated("").unwrap_err();
665        assert!(err.0.contains("empty"), "error must mention 'empty': {err}");
666    }
667
668    /// Oracle: RFC 8620 §1.2 URL-safe base64 alphabet — space (0x20) is
669    /// not in `A-Za-z0-9-_`.
670    #[test]
671    fn id_new_validated_space_fails() {
672        let err = Id::new_validated("has space").unwrap_err();
673        assert!(err.0.contains("invalid character"), "{err}");
674    }
675
676    /// Oracle: RFC 8620 §1.2 URL-safe base64 alphabet — double-quote
677    /// (0x22) is not in `A-Za-z0-9-_`.
678    #[test]
679    fn id_new_validated_dquote_fails() {
680        let err = Id::new_validated("has\"quote").unwrap_err();
681        assert!(err.0.contains("invalid character"), "{err}");
682    }
683
684    /// Oracle: RFC 8620 §1.2 URL-safe base64 alphabet — a control
685    /// character (0x01) is not in `A-Za-z0-9-_`.
686    #[test]
687    fn id_new_validated_control_char_fails() {
688        let err = Id::new_validated("has\x01ctrl").unwrap_err();
689        assert!(err.0.contains("invalid character"), "{err}");
690    }
691
692    /// Oracle: RFC 8620 §1.2 URL-safe base64 alphabet (RFC 4648 §5) —
693    /// every visible-ASCII character outside `A-Za-z0-9-_` MUST be
694    /// rejected. These characters were accepted by the previous
695    /// (incorrect) `SAFE-CHAR` validator; this test pins the spec-
696    /// conforming rejection (bd:JMAP-6xs8.19).
697    #[test]
698    fn id_new_validated_rejects_non_url_safe_base64_chars() {
699        // Representative sample of characters that are visible ASCII
700        // (the old SAFE-CHAR set) but NOT in the RFC 4648 §5 URL-safe
701        // base64 alphabet. Each MUST be rejected with category
702        // "invalid character".
703        let rejected = [
704            '!', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '.', '/', ':', ';', '<', '=',
705            '>', '?', '@', '[', '\\', ']', '^', '`', '{', '|', '}', '~',
706        ];
707        for ch in rejected {
708            let input = format!("a{ch}z");
709            let err = Id::new_validated(&input).unwrap_err();
710            assert!(
711                err.0.contains("invalid character"),
712                "char {ch:?} (input {input:?}) must be rejected with \
713                 'invalid character', got: {err}"
714            );
715        }
716    }
717
718    /// Oracle: RFC 8620 §1.2 — max length 255 bytes.
719    #[test]
720    fn id_new_validated_too_long_fails() {
721        let long = "a".repeat(256);
722        assert!(Id::new_validated(long).is_err());
723    }
724
725    /// Oracle: valid printable ASCII Id succeeds and is preserved verbatim.
726    #[test]
727    fn id_new_validated_valid_succeeds() {
728        let id = Id::new_validated("abc123-_ABC").expect("valid Id must succeed");
729        assert_eq!(id.as_ref(), "abc123-_ABC");
730    }
731
732    /// Oracle: exactly 255-byte Id succeeds.
733    #[test]
734    fn id_new_validated_max_length_succeeds() {
735        let id255 = "a".repeat(255);
736        Id::new_validated(id255).expect("255-byte Id must succeed");
737    }
738
739    /// Oracle: RFC 8620 §1.4 example "2014-10-30T06:12:00Z" must succeed.
740    #[test]
741    fn utcdate_new_validated_valid_succeeds() {
742        let d = UTCDate::new_validated("2014-10-30T06:12:00Z").expect("valid UTCDate must succeed");
743        assert_eq!(d.as_ref(), "2014-10-30T06:12:00Z");
744    }
745
746    /// Oracle: UTC date without Z suffix is not RFC 8620 §1.4 format.
747    #[test]
748    fn utcdate_new_validated_no_z_fails() {
749        assert!(UTCDate::new_validated("2014-10-30T06:12:00+00:00").is_err());
750    }
751
752    /// Oracle: empty UTCDate fails.
753    #[test]
754    fn utcdate_new_validated_empty_fails() {
755        assert!(UTCDate::new_validated("").is_err());
756    }
757
758    /// Oracle: UTCDate with wrong length fails.
759    #[test]
760    fn utcdate_new_validated_wrong_length_fails() {
761        assert!(UTCDate::new_validated("2014-10-30").is_err());
762        // fractional seconds are not permitted in RFC 8620 §1.4 format.
763        assert!(UTCDate::new_validated("2014-10-30T06:12:00.000Z").is_err());
764    }
765
766    /// Oracle: RFC 8620 §1.4 makes UTCDate an RFC 3339 "date-time"
767    /// (`time-secfrac = "." 1*DIGIT`) where "the "time-secfrac" MUST
768    /// always be omitted if zero" (rfc8620.txt:346-347). A non-zero
769    /// fraction is therefore valid.
770    #[test]
771    fn utcdate_new_validated_nonzero_secfrac_succeeds() {
772        for s in [
773            "2014-10-30T06:12:00.123Z",
774            "2014-10-30T06:12:00.1Z",
775            "2014-10-30T06:12:00.001Z",
776            "2014-10-30T06:12:00.000001Z",
777        ] {
778            assert!(UTCDate::new_validated(s).is_ok(), "{s} must be accepted");
779        }
780    }
781
782    /// Oracle: RFC 3339 `time-secfrac = "." 1*DIGIT` plus the RFC 8620
783    /// §1.4 omit-if-zero rule: an empty, zero, or non-digit fraction fails.
784    #[test]
785    fn utcdate_new_validated_bad_secfrac_fails() {
786        for s in [
787            "2014-10-30T06:12:00.Z",
788            "2014-10-30T06:12:00.0Z",
789            "2014-10-30T06:12:00.00000Z",
790            "2014-10-30T06:12:00.12aZ",
791            "2014-10-30T06:12:00.123",
792            "2014-10-30T06:12:00.123z",
793            "2014-10-30T06:12:00Z0",
794        ] {
795            let err = UTCDate::new_validated(s).unwrap_err();
796            assert!(err.to_string().contains("wrong structure"), "{s}: {err}");
797        }
798    }
799
800    /// Oracle: 2014-10-30T06:12:00Z is 1_414_649_520 (RFC 8620 §1.4
801    /// example, GNU `date -u -d 2014-10-30T06:12:00Z +%s`); a sub-second
802    /// fraction does not move it to the next second.
803    #[test]
804    fn utcdate_to_epoch_seconds_truncates_secfrac() {
805        let d = UTCDate::new_validated("2014-10-30T06:12:00.999Z").unwrap();
806        assert_eq!(d.to_epoch_seconds().unwrap(), 1_414_649_520);
807    }
808
809    /// Oracle: non-digit in year position fails.
810    #[test]
811    fn utcdate_new_validated_non_digit_fails() {
812        assert!(UTCDate::new_validated("XXXX-10-30T06:12:00Z").is_err());
813    }
814
815    /// Oracle: RFC 8620 §1.4 — month out of range MUST be rejected.
816    /// Pre-tightening, `validate_utcdate` was shape-only and would
817    /// accept this. bd:JMAP-6xs8.20 added semantic validation.
818    #[test]
819    fn utcdate_new_validated_rejects_month_out_of_range() {
820        let err = UTCDate::new_validated("2024-13-15T09:00:00Z").unwrap_err();
821        assert!(err.0.contains("month must be"), "{err}");
822        let err = UTCDate::new_validated("9999-99-99T09:00:00Z").unwrap_err();
823        assert!(err.0.contains("month must be"), "{err}");
824    }
825
826    /// Oracle: RFC 8620 §1.4 — day out of range MUST be rejected
827    /// including non-existent dates (Feb 30) and Feb 29 in non-leap
828    /// years. bd:JMAP-6xs8.20.
829    #[test]
830    fn utcdate_new_validated_rejects_day_out_of_range() {
831        // Day 32 in January
832        let err = UTCDate::new_validated("2024-01-32T09:00:00Z").unwrap_err();
833        assert!(err.0.contains("day must be"), "{err}");
834        // Feb 30 never exists
835        let err = UTCDate::new_validated("2024-02-30T09:00:00Z").unwrap_err();
836        assert!(err.0.contains("day must be"), "{err}");
837        // Feb 29 in non-leap year 2023 (not divisible by 4)
838        let err = UTCDate::new_validated("2023-02-29T09:00:00Z").unwrap_err();
839        assert!(err.0.contains("day must be"), "{err}");
840        // April 31 (April has 30 days)
841        let err = UTCDate::new_validated("2024-04-31T09:00:00Z").unwrap_err();
842        assert!(err.0.contains("day must be"), "{err}");
843    }
844
845    /// Oracle: RFC 8620 §1.4 — Feb 29 in a leap year MUST be accepted.
846    /// Pins the leap-year branch of the day-range check (2024 is
847    /// divisible by 4, not by 100, so leap). bd:JMAP-6xs8.20.
848    #[test]
849    fn utcdate_new_validated_accepts_feb_29_leap_year() {
850        UTCDate::new_validated("2024-02-29T00:00:00Z").expect("leap day must succeed");
851    }
852
853    /// Oracle: RFC 8620 §1.4 — time-component out of range MUST be
854    /// rejected. Includes hour 24, minute 60, second 60 (no leap
855    /// seconds per §1.4 even though RFC 3339 §5.6 permits them).
856    /// bd:JMAP-6xs8.20.
857    #[test]
858    fn utcdate_new_validated_rejects_time_out_of_range() {
859        let err = UTCDate::new_validated("2024-06-15T24:00:00Z").unwrap_err();
860        assert!(err.0.contains("hour must be"), "{err}");
861        let err = UTCDate::new_validated("2024-06-15T09:60:00Z").unwrap_err();
862        assert!(err.0.contains("minute must be"), "{err}");
863        let err = UTCDate::new_validated("2024-06-15T09:00:60Z").unwrap_err();
864        assert!(err.0.contains("second must be"), "{err}");
865    }
866
867    /// Oracle: the absurd case from the bd:JMAP-6xs8.20 bead body
868    /// (`9999-99-99T99:99:99Z`) MUST be rejected. Pinned here so a
869    /// future contributor can't accidentally regress to the old
870    /// shape-only validator.
871    #[test]
872    fn utcdate_new_validated_rejects_absurd_values() {
873        let err = UTCDate::new_validated("9999-99-99T99:99:99Z").unwrap_err();
874        assert!(
875            err.0.contains("month must be"),
876            "first rejected component (in left-to-right order) is the \
877             month; got: {err}"
878        );
879    }
880
881    /// Oracle: RFC 8620 §1.2 — State must be non-empty.
882    #[test]
883    fn state_new_validated_empty_fails() {
884        let err = State::new_validated("").unwrap_err();
885        assert!(err.0.contains("empty"), "{err}");
886    }
887
888    /// Oracle: non-empty State string succeeds.
889    #[test]
890    fn state_new_validated_valid_succeeds() {
891        let s = State::new_validated("75128aab4b1b").expect("valid State must succeed");
892        assert_eq!(s.as_ref(), "75128aab4b1b");
893    }
894
895    /// Oracle: ValidationError implements std::error::Error and Display.
896    #[test]
897    fn validation_error_implements_error() {
898        let e = Id::new_validated("").unwrap_err();
899        let _: &dyn std::error::Error = &e;
900        assert!(!e.to_string().is_empty(), "error message must not be empty");
901        assert_eq!(format!("{e}"), e.0, "Display must show the inner message");
902    }
903
904    // -----------------------------------------------------------------------
905    // UTCDate::to_epoch_seconds tests
906    //
907    // Oracle for all numeric vectors: Python 3 `datetime.datetime(...).
908    // replace(tzinfo=timezone.utc).timestamp()`, computed independently and
909    // hardcoded as integer literals per workspace AGENTS.md "Test vector
910    // discipline". The oracle is independent of the code under test — these
911    // are NOT round-trip self-tests.
912    // -----------------------------------------------------------------------
913
914    /// Oracle (Python): `1970-01-01T00:00:00Z` → 0.
915    #[test]
916    fn utcdate_to_epoch_seconds_unix_epoch() {
917        let d = UTCDate::new_validated("1970-01-01T00:00:00Z").unwrap();
918        assert_eq!(d.to_epoch_seconds().unwrap(), 0);
919    }
920
921    /// Oracle (Python): RFC 8620 §1.4 example `2014-10-30T06:12:00Z` →
922    /// 1_414_649_520.
923    #[test]
924    fn utcdate_to_epoch_seconds_rfc8620_example() {
925        let d = UTCDate::new_validated("2014-10-30T06:12:00Z").unwrap();
926        assert_eq!(d.to_epoch_seconds().unwrap(), 1_414_649_520);
927    }
928
929    /// Oracle (Python): `2000-01-01T00:00:00Z` (Y2K, century-leap year) →
930    /// 946_684_800.
931    #[test]
932    fn utcdate_to_epoch_seconds_y2k() {
933        let d = UTCDate::new_validated("2000-01-01T00:00:00Z").unwrap();
934        assert_eq!(d.to_epoch_seconds().unwrap(), 946_684_800);
935    }
936
937    /// Oracle (Python): `1999-12-31T23:59:59Z` (one second before Y2K) →
938    /// 946_684_799.
939    #[test]
940    fn utcdate_to_epoch_seconds_pre_y2k() {
941        let d = UTCDate::new_validated("1999-12-31T23:59:59Z").unwrap();
942        assert_eq!(d.to_epoch_seconds().unwrap(), 946_684_799);
943    }
944
945    /// Oracle (Python): `2024-02-29T00:00:00Z` (leap year, leap day start) →
946    /// 1_709_164_800.
947    #[test]
948    fn utcdate_to_epoch_seconds_leap_day_2024() {
949        let d = UTCDate::new_validated("2024-02-29T00:00:00Z").unwrap();
950        assert_eq!(d.to_epoch_seconds().unwrap(), 1_709_164_800);
951    }
952
953    /// Oracle (Python): `2024-02-29T23:59:59Z` (leap day end) →
954    /// 1_709_251_199.
955    #[test]
956    fn utcdate_to_epoch_seconds_leap_day_2024_end() {
957        let d = UTCDate::new_validated("2024-02-29T23:59:59Z").unwrap();
958        assert_eq!(d.to_epoch_seconds().unwrap(), 1_709_251_199);
959    }
960
961    /// Oracle (Python): `2100-03-01T00:00:00Z` exercises the
962    /// century-non-leap-year rule (2100 is not a leap year, divisible by 100
963    /// but not 400).
964    #[test]
965    fn utcdate_to_epoch_seconds_2100_non_leap_century() {
966        let d = UTCDate::new_validated("2100-03-01T00:00:00Z").unwrap();
967        assert_eq!(d.to_epoch_seconds().unwrap(), 4_107_542_400);
968    }
969
970    /// Oracle (Python): `1969-12-31T23:59:59Z` (one second before epoch) → -1.
971    /// Verifies negative-epoch handling.
972    #[test]
973    fn utcdate_to_epoch_seconds_one_before_epoch() {
974        let d = UTCDate::new_validated("1969-12-31T23:59:59Z").unwrap();
975        assert_eq!(d.to_epoch_seconds().unwrap(), -1);
976    }
977
978    /// Oracle (Python): `1900-01-01T00:00:00Z` → -2_208_988_800.
979    /// 1900 is divisible by 100 but not 400, so NOT a leap year — exercises
980    /// the same century rule as 2100 but well before the epoch.
981    #[test]
982    fn utcdate_to_epoch_seconds_1900() {
983        let d = UTCDate::new_validated("1900-01-01T00:00:00Z").unwrap();
984        assert_eq!(d.to_epoch_seconds().unwrap(), -2_208_988_800);
985    }
986
987    /// Oracle (Python): `0001-01-01T00:00:00Z` → -62_135_596_800.
988    /// Far-past boundary; verifies the proleptic Gregorian algorithm
989    /// extrapolates correctly to year 1.
990    #[test]
991    fn utcdate_to_epoch_seconds_year_one() {
992        let d = UTCDate::new_validated("0001-01-01T00:00:00Z").unwrap();
993        assert_eq!(d.to_epoch_seconds().unwrap(), -62_135_596_800);
994    }
995
996    /// Oracle (Python): `9999-12-31T23:59:59Z` (UTCDate max year) →
997    /// 253_402_300_799.
998    /// Far-future boundary within `i64` range.
999    #[test]
1000    fn utcdate_to_epoch_seconds_year_9999() {
1001        let d = UTCDate::new_validated("9999-12-31T23:59:59Z").unwrap();
1002        assert_eq!(d.to_epoch_seconds().unwrap(), 253_402_300_799);
1003    }
1004
1005    /// Oracle (Python): `2038-01-19T03:14:07Z` = i32::MAX = 2_147_483_647
1006    /// (the "Year 2038 problem" boundary).
1007    #[test]
1008    fn utcdate_to_epoch_seconds_y2038_boundary() {
1009        let d = UTCDate::new_validated("2038-01-19T03:14:07Z").unwrap();
1010        assert_eq!(d.to_epoch_seconds().unwrap(), 2_147_483_647);
1011    }
1012
1013    /// Oracle (Python): `2038-01-19T03:14:08Z` = i32::MAX + 1.
1014    /// Verifies `i64` handles the i32 overflow point that 32-bit time_t
1015    /// implementations would wrap at.
1016    #[test]
1017    fn utcdate_to_epoch_seconds_post_y2038() {
1018        let d = UTCDate::new_validated("2038-01-19T03:14:08Z").unwrap();
1019        assert_eq!(d.to_epoch_seconds().unwrap(), 2_147_483_648);
1020    }
1021
1022    /// Oracle: `validate_utcdate` accepts month 13 structurally; semantic
1023    /// validation in `to_epoch_seconds` must reject it.
1024    #[test]
1025    fn utcdate_to_epoch_seconds_rejects_month_13() {
1026        let d = UTCDate::from("2024-13-01T00:00:00Z");
1027        let err = d.to_epoch_seconds().unwrap_err();
1028        assert!(err.0.contains("month"), "error must mention month: {err}");
1029    }
1030
1031    /// Oracle: `validate_utcdate` accepts day 32 structurally; semantic
1032    /// validation in `to_epoch_seconds` must reject it.
1033    #[test]
1034    fn utcdate_to_epoch_seconds_rejects_day_32() {
1035        let d = UTCDate::from("2024-01-32T00:00:00Z");
1036        let err = d.to_epoch_seconds().unwrap_err();
1037        assert!(err.0.contains("day"), "error must mention day: {err}");
1038    }
1039
1040    /// Oracle: 2023 is NOT a leap year; Feb 29 must be rejected as
1041    /// out-of-range for that month.
1042    #[test]
1043    fn utcdate_to_epoch_seconds_rejects_feb_29_non_leap() {
1044        let d = UTCDate::from("2023-02-29T00:00:00Z");
1045        let err = d.to_epoch_seconds().unwrap_err();
1046        assert!(err.0.contains("day"), "error must mention day: {err}");
1047    }
1048
1049    /// Oracle: hour 24 is out of range per RFC 8620 §1.4 (no end-of-day
1050    /// convention) — `to_epoch_seconds` must reject it.
1051    #[test]
1052    fn utcdate_to_epoch_seconds_rejects_hour_24() {
1053        let d = UTCDate::from("2024-01-01T24:00:00Z");
1054        let err = d.to_epoch_seconds().unwrap_err();
1055        assert!(err.0.contains("hour"), "error must mention hour: {err}");
1056    }
1057
1058    /// Oracle: minute 60 out of range.
1059    #[test]
1060    fn utcdate_to_epoch_seconds_rejects_minute_60() {
1061        let d = UTCDate::from("2024-01-01T00:60:00Z");
1062        let err = d.to_epoch_seconds().unwrap_err();
1063        assert!(err.0.contains("minute"), "error must mention minute: {err}");
1064    }
1065
1066    /// Oracle: second 60 (leap second) is not permitted by RFC 8620 §1.4 —
1067    /// `to_epoch_seconds` must reject it.
1068    #[test]
1069    fn utcdate_to_epoch_seconds_rejects_leap_second() {
1070        let d = UTCDate::from("2016-12-31T23:59:60Z");
1071        let err = d.to_epoch_seconds().unwrap_err();
1072        assert!(err.0.contains("second"), "error must mention second: {err}");
1073    }
1074
1075    /// Oracle: a value constructed via `UTCDate::from` with wrong structure
1076    /// must surface a structural ValidationError (re-validation in
1077    /// `to_epoch_seconds`).
1078    #[test]
1079    fn utcdate_to_epoch_seconds_rejects_invalid_structure() {
1080        let d = UTCDate::from("not-a-date");
1081        assert!(d.to_epoch_seconds().is_err());
1082    }
1083
1084    /// Oracle (Python): `2024-02-28T23:59:59Z` → `2024-02-29T00:00:00Z` is
1085    /// exactly one second forward. Verifies leap-day arithmetic is
1086    /// internally consistent.
1087    #[test]
1088    fn utcdate_to_epoch_seconds_leap_day_boundary_one_sec() {
1089        let a = UTCDate::new_validated("2024-02-28T23:59:59Z").unwrap();
1090        let b = UTCDate::new_validated("2024-02-29T00:00:00Z").unwrap();
1091        let diff = b.to_epoch_seconds().unwrap() - a.to_epoch_seconds().unwrap();
1092        assert_eq!(
1093            diff, 1,
1094            "Feb 28 23:59:59 → Feb 29 00:00:00 must be 1 second"
1095        );
1096    }
1097
1098    /// Oracle (Python): a 365-day duration `2023-01-01T00:00:00Z` to
1099    /// `2024-01-01T00:00:00Z` is exactly `365 * 86_400 = 31_536_000` seconds
1100    /// (2023 is not a leap year). Anchors the `CalendarsLimits::default()
1101    /// .max_expanded_query_duration_seconds` value (`31_536_000`).
1102    #[test]
1103    fn utcdate_to_epoch_seconds_365_day_year_duration() {
1104        let a = UTCDate::new_validated("2023-01-01T00:00:00Z").unwrap();
1105        let b = UTCDate::new_validated("2024-01-01T00:00:00Z").unwrap();
1106        let diff = b.to_epoch_seconds().unwrap() - a.to_epoch_seconds().unwrap();
1107        assert_eq!(diff, 31_536_000);
1108    }
1109
1110    /// Oracle (Python): a 366-day duration `2024-01-01T00:00:00Z` to
1111    /// `2025-01-01T00:00:00Z` is `366 * 86_400 = 31_622_400` seconds (2024 is
1112    /// a leap year).
1113    #[test]
1114    fn utcdate_to_epoch_seconds_366_day_leap_year_duration() {
1115        let a = UTCDate::new_validated("2024-01-01T00:00:00Z").unwrap();
1116        let b = UTCDate::new_validated("2025-01-01T00:00:00Z").unwrap();
1117        let diff = b.to_epoch_seconds().unwrap() - a.to_epoch_seconds().unwrap();
1118        assert_eq!(diff, 31_622_400);
1119    }
1120}