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}