feat(prefs): user_ai_prefs surface — GET/PUT /prefs, GET /timezones (#126)
- 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<Option<T>>
- 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
This commit is contained in:
1 parent
7f348022d4
commit
2258a0eab5
3 files changed
+732
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
+724
@@ -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<String>,
|
||||
pub library: Option<String>,
|
||||
pub display_tz: Option<String>,
|
||||
pub updated_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// 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<Option<T>>` (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<D>(deserializer: D) -> Result<TriState, D::Error>
|
||||
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<E: serde::de::Error>(self) -> Result<TriState, E> {
|
||||
Ok(TriState::Null)
|
||||
}
|
||||
fn visit_none<E: serde::de::Error>(self) -> Result<TriState, E> {
|
||||
Ok(TriState::Null)
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<TriState, E> {
|
||||
Ok(TriState::Val(v.to_string()))
|
||||
}
|
||||
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<TriState, E> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<String> {
|
||||
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<String>,
|
||||
) -> Option<String> {
|
||||
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<String>, Option<String>, Option<String>, 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<AppState>) -> 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<UpdatePrefsRequest>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> 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<AppState>, SqliteUserAiPrefsDao) {
|
||||
let conn = Arc::new(Mutex::new(crate::database::test::in_memory_db_connection()));
|
||||
let mut state = AppState::test_state();
|
||||
let state_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>> = 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user