From 2258a0eab58dbc5ac2a8253eb35e3c0673e7e4e1 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sun, 27 Sep 2026 14:40:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(prefs):=20user=5Fai=5Fprefs=20surface=20?= =?UTF-8?q?=E2=80=94=20GET/PUT=20/prefs,=20GET=20/timezones=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /prefs (JWT): current row or empty {voice, library, display_tz, updated_at} shape — snake_case, 200 with nulls when no row - PUT /prefs (JWT): three-state partial update (omitted=unchanged, explicit null=clear, value=set) via hand-rolled TriState deserializer — serde derive conflates omitted with explicit null on Option> - display_tz validated against chrono-tz before any write; invalid → 400, nothing written. tz_offset_minutes (frozen legacy) preserved verbatim - GET /timezones (public): chrono_tz::TZ_VARIANTS — exactly the domain Tz::from_str accepts, so picker list and validation can't drift - No migration; existing columns only. capture_prefs (POST /reels) untouched - 14 tests: 3 GET /prefs, 7 PUT /prefs (incl. auth-gate), 2 /timezones, 2 pure merge_patch. Full suite 1436 passed / 0 failed --- src/lib.rs | 1 + src/main.rs | 7 + src/prefs.rs | 724 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 732 insertions(+) create mode 100644 src/prefs.rs diff --git a/src/lib.rs b/src/lib.rs index ceda9a5..be02c53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,7 @@ pub mod otel; pub mod parsers; pub mod perceptual_hash; pub mod personas; +pub mod prefs; pub mod service; pub mod state; pub mod tags; diff --git a/src/main.rs b/src/main.rs index 03502a4..ab71b3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,6 +68,7 @@ mod knowledge; mod memories; mod otel; mod personas; +mod prefs; mod reels; mod service; #[cfg(test)] @@ -374,6 +375,12 @@ fn main() -> std::io::Result<()> { .service(reels::reel_video_handler) .service(reels::precomputed_reel_handler) .service(reels::precomputed_video_handler) + .service( + web::resource("/prefs") + .route(web::get().to(prefs::get_prefs)) + .route(web::put().to(prefs::put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(prefs::list_timezones))) .service(ai::generate_insight_handler) .service(ai::generate_agentic_insight_handler) .service(ai::generation_status_handler) diff --git a/src/prefs.rs b/src/prefs.rs new file mode 100644 index 0000000..32b8737 --- /dev/null +++ b/src/prefs.rs @@ -0,0 +1,724 @@ +//! HTTP handlers for the `user_ai_prefs` settings surface. +//! +//! `GET /prefs` — the current row (or the empty shape when none exists). +//! `PUT /prefs` — partial update: an omitted field is unchanged, an explicit +//! null clears, a value sets. `display_tz` is validated against chrono-tz +//! (the same check as `camera_tz::display_zone_for`); an invalid zone name +//! → 400 and nothing is written. +//! `GET /timezones` — the IANA zone list from chrono-tz, for picker UIs +//! (unauthenticated; static data). +//! +//! Scope: existing columns only (voice, library, display_tz) — no migration. +//! The legacy `tz_offset_minutes` column is preserved verbatim on every +//! write. `POST /reels`'s `capture_prefs` stays as the incidental writer; +//! both writers hit the same single row, so they're idempotent. Owner +//! defaults (model/backend/persona/reels) are a separate follow-on ticket +//! that adds the columns. + +use actix_web::{HttpResponse, web}; +use chrono_tz::Tz; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::data::Claims; +use crate::database::models::{UpsertUserAiPrefs, UserAiPrefs}; +use crate::state::AppState; + +// --------------------------------------------------------------------------- +// Wire shapes — snake_case, matching the `display_tz` contract surface. +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Debug)] +pub struct PrefsResponse { + pub voice: Option, + pub library: Option, + pub display_tz: Option, + pub updated_at: Option, +} + +/// One partial-update field. `Absent` = key omitted (unchanged), `Null` = +/// explicit JSON null (clear), `Val(v)` = set. serde's derive can't keep +/// explicit null and a missing key apart on `Option>` (both come +/// out as outer `None`), so this carries a hand-rolled visitor. +#[derive(Clone, Debug, Default)] +pub enum TriState { + #[default] + Absent, + Null, + Val(String), +} + +impl<'de> Deserialize<'de> for TriState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = TriState; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a string or null") + } + // serde_json routes an explicit JSON null to visit_unit/visit_none; + // a missing key never calls deserialize — `#[serde(default)]` + // supplies Absent instead. + fn visit_unit(self) -> Result { + Ok(TriState::Null) + } + fn visit_none(self) -> Result { + Ok(TriState::Null) + } + fn visit_str(self, v: &str) -> Result { + Ok(TriState::Val(v.to_string())) + } + fn visit_string(self, v: String) -> Result { + Ok(TriState::Val(v)) + } + } + deserializer.deserialize_any(Visitor) + } +} + +/// Partial update: an omitted field is unchanged, an explicit null clears, +/// a value sets. +#[derive(Deserialize, Debug, Default)] +pub struct UpdatePrefsRequest { + #[serde(default)] + pub voice: TriState, + #[serde(default)] + pub library: TriState, + #[serde(default)] + pub display_tz: TriState, +} + +#[derive(Serialize, Debug)] +pub struct TimezonesResponse { + pub timezones: Vec, +} + +// --------------------------------------------------------------------------- +// Pure seams +// --------------------------------------------------------------------------- + +/// The same zone-name check as `camera_tz::display_zone_for` (trim, then +/// chrono-tz parse). Used to reject writes with an unparseable zone before +/// any DB touch. +fn is_valid_display_tz(name: &str) -> bool { + Tz::from_str(name.trim()).is_ok() +} + +/// The full set of zone names `PUT /prefs` will accept — `chrono_tz::TZ_VARIANTS` +/// is exactly the domain of `Tz::from_str`, so the picker list and the +/// validation check can never drift apart. +pub fn all_timezones() -> Vec { + chrono_tz::TZ_VARIANTS + .iter() + .map(|tz| tz.to_string()) + .collect() +} + +/// Merge a partial update onto the existing row. Returns +/// `(voice, library, display_tz, changed)`. An omitted field keeps the +/// stored value (or `None` when there is no row); a present field wins. +fn tri_value( + field: &TriState, + existing: Option<&UserAiPrefs>, + keep: impl FnOnce(&UserAiPrefs) -> Option, +) -> Option { + match field { + TriState::Absent => existing.and_then(keep), + TriState::Null => None, + TriState::Val(v) => Some(v.clone()), + } +} + +fn merge_patch( + existing: Option<&UserAiPrefs>, + patch: &UpdatePrefsRequest, +) -> (Option, Option, Option, bool) { + let voice = tri_value(&patch.voice, existing, |e| e.voice.clone()); + let library = tri_value(&patch.library, existing, |e| e.library.clone()); + let display_tz = tri_value(&patch.display_tz, existing, |e| e.display_tz.clone()); + let changed = !matches!(patch.voice, TriState::Absent) + || !matches!(patch.library, TriState::Absent) + || !matches!(patch.display_tz, TriState::Absent); + (voice, library, display_tz, changed) +} + +fn row_to_response(row: Option<&UserAiPrefs>) -> PrefsResponse { + match row { + Some(row) => PrefsResponse { + voice: row.voice.clone(), + library: row.library.clone(), + display_tz: row.display_tz.clone(), + updated_at: Some(row.updated_at), + }, + None => PrefsResponse { + voice: None, + library: None, + display_tz: None, + updated_at: None, + }, + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +pub async fn get_prefs(_claims: Claims, app_state: web::Data) -> HttpResponse { + let mut dao = match app_state.user_ai_prefs_dao.lock() { + Ok(dao) => dao, + Err(_) => { + return HttpResponse::InternalServerError().json(json!({ + "error": "prefs store unavailable" + })); + } + }; + match dao.get_prefs(&opentelemetry::Context::new()) { + Ok(row) => HttpResponse::Ok().json(row_to_response(row.as_ref())), + Err(e) => HttpResponse::InternalServerError().json(json!({ + "error": format!("failed to read prefs: {e}") + })), + } +} + +pub async fn put_prefs( + _claims: Claims, + body: web::Json, + app_state: web::Data, +) -> HttpResponse { + let patch = body.0; + + // Validate before any write: an invalid zone name → 400, nothing written. + if let TriState::Val(name) = &patch.display_tz + && !is_valid_display_tz(name) + { + return HttpResponse::BadRequest().json(json!({ + "error": format!("invalid display_tz: {name}") + })); + } + + let mut dao = match app_state.user_ai_prefs_dao.lock() { + Ok(dao) => dao, + Err(_) => { + return HttpResponse::InternalServerError().json(json!({ + "error": "prefs store unavailable" + })); + } + }; + let ctx = opentelemetry::Context::new(); + let existing = match dao.get_prefs(&ctx) { + Ok(row) => row, + Err(e) => { + return HttpResponse::InternalServerError().json(json!({ + "error": format!("failed to read prefs: {e}") + })); + } + }; + + let (voice, library, display_tz, changed) = merge_patch(existing.as_ref(), &patch); + if !changed { + // Empty body: no-op, return the current row without bumping updated_at. + return HttpResponse::Ok().json(row_to_response(existing.as_ref())); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() as i64; + + let resp = PrefsResponse { + voice: voice.clone(), + library: library.clone(), + display_tz: display_tz.clone(), + updated_at: Some(now), + }; + let upsert = UpsertUserAiPrefs { + voice, + // Frozen legacy column: preserved verbatim, never written by this + // surface. + tz_offset_minutes: existing.as_ref().and_then(|e| e.tz_offset_minutes), + library, + display_tz, + updated_at: now, + }; + match dao.upsert_prefs(&ctx, &upsert) { + Ok(()) => HttpResponse::Ok().json(resp), + Err(e) => HttpResponse::InternalServerError().json(json!({ + "error": format!("failed to write prefs: {e}") + })), + } +} + +pub async fn list_timezones() -> HttpResponse { + HttpResponse::Ok().json(TimezonesResponse { + timezones: all_timezones(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use actix_web::App; + use actix_web::test::{TestRequest, call_service, init_service, read_body_json}; + use std::sync::{Arc, Mutex}; + + use crate::database::{SqliteUserAiPrefsDao, UserAiPrefsDao}; + + /// AppState whose prefs DAO shares one in-memory connection, plus a + /// second DAO handle on the same connection for seeding. + fn prefs_state() -> (web::Data, SqliteUserAiPrefsDao) { + let conn = Arc::new(Mutex::new(crate::database::test::in_memory_db_connection())); + let mut state = AppState::test_state(); + let state_dao: Arc>> = Arc::new(Mutex::new(Box::new( + SqliteUserAiPrefsDao::from_connection(conn.clone()), + ))); + state.user_ai_prefs_dao = state_dao; + let seed_dao = SqliteUserAiPrefsDao::from_connection(conn); + (web::Data::new(state), seed_dao) + } + + fn make_token() -> String { + let claims = Claims::valid_user(String::from("1")); + jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &claims, + &jsonwebtoken::EncodingKey::from_secret(b"test_key"), + ) + .unwrap() + } + + fn auth() -> (&'static str, String) { + ("Authorization", format!("Bearer {}", make_token())) + } + + fn seed(dao: &mut SqliteUserAiPrefsDao, row: UpsertUserAiPrefs) { + dao.upsert_prefs(&opentelemetry::Context::new(), &row) + .expect("seed prefs"); + } + + fn seeded_row( + voice: Option<&str>, + library: Option<&str>, + display_tz: Option<&str>, + ) -> UpsertUserAiPrefs { + UpsertUserAiPrefs { + voice: voice.map(str::to_string), + tz_offset_minutes: Some(-240), + library: library.map(str::to_string), + updated_at: 1_700_000_000, + display_tz: display_tz.map(str::to_string), + } + } + + // --- GET /prefs --------------------------------------------------------- + + #[actix_rt::test] + async fn get_prefs_requires_auth() { + let (state, _seed) = prefs_state(); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let req = TestRequest::get().uri("/prefs").to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), 401); + } + + #[actix_rt::test] + async fn get_prefs_returns_empty_shape_when_no_row() { + let (state, _seed) = prefs_state(); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let (header, value) = auth(); + let req = TestRequest::get() + .uri("/prefs") + .insert_header((header, value)) + .to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = read_body_json(resp).await; + assert_eq!( + body, + serde_json::json!({ + "voice": null, + "library": null, + "display_tz": null, + "updated_at": null + }) + ); + } + + #[actix_rt::test] + async fn get_prefs_returns_stored_row() { + let (state, mut seed_dao) = prefs_state(); + seed( + &mut seed_dao, + seeded_row(Some("grandma"), Some("2"), Some("America/New_York")), + ); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let (header, value) = auth(); + let req = TestRequest::get() + .uri("/prefs") + .insert_header((header, value)) + .to_request(); + let resp = call_service(&app, req).await; + let body: serde_json::Value = read_body_json(resp).await; + assert_eq!(body["voice"], "grandma"); + assert_eq!(body["library"], "2"); + assert_eq!(body["display_tz"], "America/New_York"); + assert_eq!(body["updated_at"], 1_700_000_000); + } + + // --- PUT /prefs --------------------------------------------------------- + + #[actix_rt::test] + async fn put_prefs_requires_auth() { + let (state, _seed) = prefs_state(); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let req = TestRequest::put() + .uri("/prefs") + .set_json(serde_json::json!({"voice": "dad"})) + .to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), 401); + } + + #[actix_rt::test] + async fn put_prefs_partial_update_leaves_omitted_fields_untouched() { + let (state, mut seed_dao) = prefs_state(); + seed( + &mut seed_dao, + seeded_row(Some("grandma"), Some("1"), Some("America/New_York")), + ); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let (header, value) = auth(); + let req = TestRequest::put() + .uri("/prefs") + .insert_header((header, value)) + .set_json(serde_json::json!({"voice": "dad"})) + .to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), 200); + + let mut dao = state.user_ai_prefs_dao.lock().unwrap(); + let row = dao + .get_prefs(&opentelemetry::Context::new()) + .unwrap() + .unwrap(); + assert_eq!(row.voice.as_deref(), Some("dad")); + assert_eq!(row.library.as_deref(), Some("1")); + assert_eq!(row.display_tz.as_deref(), Some("America/New_York")); + // The frozen legacy column survives the write. + assert_eq!(row.tz_offset_minutes, Some(-240)); + assert!(row.updated_at > 1_700_000_000); + } + + #[actix_rt::test] + async fn put_prefs_explicit_null_clears_field() { + let (state, mut seed_dao) = prefs_state(); + seed( + &mut seed_dao, + seeded_row(Some("grandma"), Some("1"), Some("America/New_York")), + ); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let (header, value) = auth(); + let req = TestRequest::put() + .uri("/prefs") + .insert_header((header, value)) + .set_json(serde_json::json!({"voice": null})) + .to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), 200); + + let mut dao = state.user_ai_prefs_dao.lock().unwrap(); + let row = dao + .get_prefs(&opentelemetry::Context::new()) + .unwrap() + .unwrap(); + assert!(row.voice.is_none()); + assert_eq!(row.library.as_deref(), Some("1")); + assert_eq!(row.display_tz.as_deref(), Some("America/New_York")); + } + + #[actix_rt::test] + async fn put_prefs_inserts_row_when_none_exists() { + let (state, _seed) = prefs_state(); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let (header, value) = auth(); + let req = TestRequest::put() + .uri("/prefs") + .insert_header((header, value)) + .set_json(serde_json::json!({"library": "1"})) + .to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), 200); + + let mut dao = state.user_ai_prefs_dao.lock().unwrap(); + let row = dao + .get_prefs(&opentelemetry::Context::new()) + .unwrap() + .unwrap(); + assert_eq!(row.library.as_deref(), Some("1")); + assert!(row.voice.is_none()); + assert!(row.display_tz.is_none()); + } + + #[actix_rt::test] + async fn put_prefs_invalid_display_tz_returns_400_and_writes_nothing() { + let (state, mut seed_dao) = prefs_state(); + seed( + &mut seed_dao, + seeded_row(Some("grandma"), Some("1"), Some("America/New_York")), + ); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let (header, value) = auth(); + let req = TestRequest::put() + .uri("/prefs") + .insert_header((header, value)) + .set_json(serde_json::json!({"voice": "dad", "display_tz": "Not/A_Zone"})) + .to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), 400); + + // Nothing written — not even the valid field in the same body. + let mut dao = state.user_ai_prefs_dao.lock().unwrap(); + let row = dao + .get_prefs(&opentelemetry::Context::new()) + .unwrap() + .unwrap(); + assert_eq!(row.voice.as_deref(), Some("grandma")); + assert_eq!(row.display_tz.as_deref(), Some("America/New_York")); + assert_eq!(row.updated_at, 1_700_000_000); + } + + #[actix_rt::test] + async fn put_prefs_valid_display_tz_persists() { + let (state, _seed) = prefs_state(); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let (header, value) = auth(); + let req = TestRequest::put() + .uri("/prefs") + .insert_header((header, value)) + .set_json(serde_json::json!({"display_tz": "Europe/Berlin"})) + .to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), 200); + + let mut dao = state.user_ai_prefs_dao.lock().unwrap(); + let row = dao + .get_prefs(&opentelemetry::Context::new()) + .unwrap() + .unwrap(); + assert_eq!(row.display_tz.as_deref(), Some("Europe/Berlin")); + } + + #[actix_rt::test] + async fn put_prefs_empty_body_is_noop() { + let (state, _seed) = prefs_state(); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let (header, value) = auth(); + let req = TestRequest::put() + .uri("/prefs") + .insert_header((header, value)) + .set_json(serde_json::json!({})) + .to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = read_body_json(resp).await; + assert_eq!(body["updated_at"], serde_json::Value::Null); + + let mut dao = state.user_ai_prefs_dao.lock().unwrap(); + assert!( + dao.get_prefs(&opentelemetry::Context::new()) + .unwrap() + .is_none() + ); + } + + // --- GET /timezones ----------------------------------------------------- + + #[actix_rt::test] + async fn list_timezones_is_unauthenticated_and_includes_iana_zones() { + let (state, _seed) = prefs_state(); + let app = init_service( + App::new() + .service( + web::resource("/prefs") + .route(web::get().to(get_prefs)) + .route(web::put().to(put_prefs)), + ) + .service(web::resource("/timezones").route(web::get().to(list_timezones))) + .app_data(state.clone()), + ) + .await; + let req = TestRequest::get().uri("/timezones").to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = read_body_json(resp).await; + let zones: Vec<&str> = body["timezones"] + .as_array() + .expect("timezones is an array") + .iter() + .map(|v| v.as_str().expect("zone is a string")) + .collect(); + assert!( + zones.contains(&"America/New_York"), + "IANA list must include America/New_York" + ); + assert!(zones.len() > 400, "IANA list looks truncated"); + } + + #[test] + fn all_timezones_is_exactly_the_from_str_domain() { + // The picker list and the PUT /prefs validation must never drift: + // every listed name parses, and the list is the full variant set. + let zones = all_timezones(); + assert_eq!(zones.len(), chrono_tz::TZ_VARIANTS.len()); + for z in &zones { + assert!(Tz::from_str(z).is_ok(), "listed zone must parse: {z}"); + } + assert!(zones.contains(&"America/New_York".to_string())); + } + + // --- merge_patch (pure) -------------------------------------------------- + + #[test] + fn merge_patch_omitted_keeps_existing() { + let row = UserAiPrefs { + id: 1, + voice: Some("a".into()), + tz_offset_minutes: None, + library: Some("b".into()), + updated_at: 0, + display_tz: Some("c".into()), + }; + let (voice, library, display_tz, changed) = + merge_patch(Some(&row), &UpdatePrefsRequest::default()); + assert_eq!(voice.as_deref(), Some("a")); + assert_eq!(library.as_deref(), Some("b")); + assert_eq!(display_tz.as_deref(), Some("c")); + assert!(!changed); + } + + #[test] + fn merge_patch_present_field_wins_including_null() { + let row = UserAiPrefs { + id: 1, + voice: Some("a".into()), + tz_offset_minutes: None, + library: None, + updated_at: 0, + display_tz: None, + }; + let patch = UpdatePrefsRequest { + voice: TriState::Null, // clear + library: TriState::Val(String::from("x")), // set + display_tz: TriState::Absent, + }; + let (voice, library, display_tz, changed) = merge_patch(Some(&row), &patch); + assert!(voice.is_none()); + assert_eq!(library.as_deref(), Some("x")); + assert!(display_tz.is_none()); + assert!(changed); + } +}