use chrono::{DateTime, TimeZone, Utc};
use serde_json::json;
use sqlx::SqlitePool;
use tokio::sync::OnceCell;
use umbral::migrate::ModelMeta;
use umbral::orm::DynQuerySet;
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]
#[umbral(table = "dttz_event")]
pub struct DttzEvent {
pub id: i64,
pub label: String,
pub at: DateTime<Utc>,
}
static BOOT: OnceCell<SqlitePool> = OnceCell::const_new();
async fn boot() -> SqlitePool {
BOOT.get_or_init(build_once).await.clone()
}
async fn build_once() -> SqlitePool {
let pool = umbral::db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite");
let mut settings = umbral::Settings::from_env().expect("settings");
settings.database_url = "sqlite::memory:".to_string();
settings.time_zone = Some("America/New_York".to_string());
umbral::App::builder()
.settings(settings)
.database("default", pool.clone())
.model::<DttzEvent>()
.build_deferred()
.expect("App::build_deferred");
umbral_core::migrate::create_tables_for_tests()
.await
.expect("create the test schema");
pool
}
async fn insert_at(label: &str, at: &str) -> Result<(), String> {
let meta = ModelMeta::for_::<DttzEvent>();
let mut values = serde_json::Map::new();
values.insert("label".into(), json!(label));
values.insert("at".into(), json!(at));
DynQuerySet::for_meta(&meta)
.insert_json(&values)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn stored(label: &str) -> DateTime<Utc> {
DttzEvent::objects()
.filter(dttz_event::LABEL.eq(label))
.first()
.await
.expect("query")
.unwrap_or_else(|| panic!("row `{label}` should exist"))
.at
}
#[tokio::test]
async fn a_naive_input_is_interpreted_in_the_project_timezone() {
boot().await;
insert_at("summer", "2026-07-10T12:00:00")
.await
.expect("unambiguous summer time");
assert_eq!(
stored("summer").await,
Utc.with_ymd_and_hms(2026, 7, 10, 16, 0, 0).unwrap(),
"noon EDT is 16:00Z",
);
insert_at("winter", "2026-01-10T12:00:00")
.await
.expect("unambiguous winter time");
assert_eq!(
stored("winter").await,
Utc.with_ymd_and_hms(2026, 1, 10, 17, 0, 0).unwrap(),
"noon EST is 17:00Z",
);
}
#[tokio::test]
async fn an_explicit_offset_beats_the_project_timezone() {
boot().await;
insert_at("explicit", "2026-07-10T12:00:00+03:00")
.await
.expect("offset input");
assert_eq!(
stored("explicit").await,
Utc.with_ymd_and_hms(2026, 7, 10, 9, 0, 0).unwrap(),
"the carried offset is ground truth; the project tz must not re-interpret it",
);
}
#[tokio::test]
async fn an_ambiguous_local_time_is_rejected_not_silently_shifted() {
boot().await;
let err = insert_at("ambiguous", "2026-11-01T01:30:00")
.await
.expect_err("the DST overlap hour is ambiguous and must be rejected");
assert!(
err.to_lowercase().contains("at"),
"the error must name the offending field; got: {err}",
);
assert!(
err.to_lowercase().contains("ambiguous"),
"the error must say the local time is ambiguous, so the caller can ask \
for an explicit offset; got: {err}",
);
let count = DttzEvent::objects()
.filter(dttz_event::LABEL.eq("ambiguous"))
.count()
.await
.expect("count");
assert_eq!(count, 0, "a rejected write must not leave a row");
}
#[tokio::test]
async fn a_nonexistent_local_time_is_rejected() {
boot().await;
let err = insert_at("nonexistent", "2026-03-08T02:30:00")
.await
.expect_err("the spring-forward gap hour does not exist and must be rejected");
assert!(
err.to_lowercase().contains("at"),
"the error must name the offending field; got: {err}",
);
assert!(
err.to_lowercase().contains("does not exist") || err.to_lowercase().contains("nonexistent"),
"the error must say the local time does not exist; got: {err}",
);
}