trace_db_call no longer takes an OpenTelemetry Context: it opens a db.<query_type>.<operation> span (same name via otel.name, same db.query_type / db.operation attrs) on the caller's ambient tracing span and runs the DAO body inside it. The cx / ctx / context parameter is gone from every DAO trait method, impl and call site, and from the helpers and background drains that only threaded it. The remaining request-handler spans (files, tags, faces, image, video, favorites, duplicates, memories) move to #[instrument]; the Context::current_with_span / with_remote_span_context plumbing is gone, as is extract_context_from_request (the tracing-actix-web root span covers it). otel::set_span_attributes replaces BoxedSpan::set_attributes. Background-job raw spans (thumbnails, video actors) are left for #142. tests/otel_db_spans.rs pins name, attributes, status and nesting (across an instrumented spawn); otel_traceparent now uses an #[instrument] handler. CLAUDE.md documents the tracing conventions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1034 lines
39 KiB
Rust
1034 lines
39 KiB
Rust
//! 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).
|
|
//!
|
|
//! Surface: all persisted prefs — the passive mirror (voice, library,
|
|
//! display_tz) and the owner defaults (default_model, default_backend,
|
|
//! default_persona_id, reel_span, reel_max_segments). Consumers resolve
|
|
//! `request param → stored pref → hard default`; this endpoint is the only
|
|
//! place the owner-default fields are written. `POST /reels`'s
|
|
//! `capture_prefs` stays as the incidental writer for the mirror fields
|
|
//! (plus the effective reel span/segments); both writers hit the same
|
|
//! single row, so they're idempotent. The legacy `tz_offset_minutes`
|
|
//! column is preserved verbatim on every write.
|
|
|
|
use actix_web::{HttpResponse, web};
|
|
use chrono_tz::Tz;
|
|
use serde::de::IntoDeserializer;
|
|
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::memories::MemoriesSpan;
|
|
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>,
|
|
/// Last/explicit insight + chat model choice. NULL = per-request /
|
|
/// env default behavior.
|
|
pub default_model: Option<String>,
|
|
/// `"local"` | `"hybrid"`. NULL = per-request / hard default.
|
|
pub default_backend: Option<String>,
|
|
/// Active persona for persona chat. NULL = hard default ("default").
|
|
pub default_persona_id: Option<String>,
|
|
/// Reel span: `"day"` | `"week"` | `"month"`. NULL = per-request /
|
|
/// hard default (day).
|
|
pub reel_span: Option<String>,
|
|
/// Reel segment cap. NULL = per-request / hard default.
|
|
pub reel_max_segments: Option<i32>,
|
|
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<T> {
|
|
#[default]
|
|
Absent,
|
|
Null,
|
|
Val(T),
|
|
}
|
|
|
|
impl<'de, T: Deserialize<'de>> Deserialize<'de> for TriState<T> {
|
|
fn deserialize<D>(deserializer: D) -> Result<TriState<T>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
struct Visitor<T>(std::marker::PhantomData<T>);
|
|
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Visitor<T> {
|
|
type Value = TriState<T>;
|
|
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
f.write_str("a value 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. Concrete values are re-deserialized into
|
|
// T through single-value deserializers (serde_json's
|
|
// deserialize_any dispatches directly to visit_str/visit_i64/…
|
|
// — visit_some only fires on the deserialize_option path).
|
|
fn visit_unit<E: serde::de::Error>(self) -> Result<TriState<T>, E> {
|
|
Ok(TriState::Null)
|
|
}
|
|
fn visit_none<E: serde::de::Error>(self) -> Result<TriState<T>, E> {
|
|
Ok(TriState::Null)
|
|
}
|
|
fn visit_some<D2>(self, d: D2) -> Result<TriState<T>, D2::Error>
|
|
where
|
|
D2: serde::Deserializer<'de>,
|
|
{
|
|
Ok(TriState::Val(T::deserialize(d)?))
|
|
}
|
|
// `into_deserializer()` gives a single-value deserializer whose
|
|
// error type is the visitor's E, so T::deserialize lands on
|
|
// Result<T, E> with no conversion.
|
|
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<TriState<T>, E> {
|
|
T::deserialize(v.into_deserializer()).map(TriState::Val)
|
|
}
|
|
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<TriState<T>, E> {
|
|
T::deserialize(v.as_str().into_deserializer()).map(TriState::Val)
|
|
}
|
|
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<TriState<T>, E> {
|
|
T::deserialize(v.into_deserializer()).map(TriState::Val)
|
|
}
|
|
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<TriState<T>, E> {
|
|
T::deserialize(v.into_deserializer()).map(TriState::Val)
|
|
}
|
|
}
|
|
deserializer.deserialize_any(Visitor(std::marker::PhantomData))
|
|
}
|
|
}
|
|
|
|
/// 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<String>,
|
|
#[serde(default)]
|
|
pub library: TriState<String>,
|
|
#[serde(default)]
|
|
pub display_tz: TriState<String>,
|
|
#[serde(default)]
|
|
pub default_model: TriState<String>,
|
|
#[serde(default)]
|
|
pub default_backend: TriState<String>,
|
|
#[serde(default)]
|
|
pub default_persona_id: TriState<String>,
|
|
#[serde(default)]
|
|
pub reel_span: TriState<String>,
|
|
#[serde(default)]
|
|
pub reel_max_segments: TriState<i32>,
|
|
}
|
|
|
|
#[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()
|
|
}
|
|
|
|
/// A stored `default_backend` must be consumable downstream, i.e. accepted
|
|
/// by the canonical `BackendKind::parse` ("local"/"hybrid", case-insensitive).
|
|
/// Delegating to that parser keeps the write-time validator and the read-time
|
|
/// consumer from drifting apart; anything else stored here would 500 on every
|
|
/// insight/chat request instead of failing once at write time. The blank
|
|
/// string the parser tolerates (empty request field = app default) is
|
|
/// rejected here — a stored default is an explicit choice, and NULL already
|
|
/// means "no default".
|
|
fn is_valid_backend(name: &str) -> bool {
|
|
!name.trim().is_empty() && crate::ai::backend::BackendKind::parse(name).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. An omitted field keeps
|
|
/// the stored value (or `None` when there is no row); a present field wins.
|
|
fn tri_value<T: Clone>(
|
|
field: &TriState<T>,
|
|
existing: Option<&UserAiPrefs>,
|
|
keep: impl FnOnce(&UserAiPrefs) -> Option<T>,
|
|
) -> Option<T> {
|
|
match field {
|
|
TriState::Absent => existing.and_then(keep),
|
|
TriState::Null => None,
|
|
TriState::Val(v) => Some(v.clone()),
|
|
}
|
|
}
|
|
|
|
/// The eight partial-update fields after a merge (everything except the
|
|
/// frozen `tz_offset_minutes`, which the handler copies through verbatim).
|
|
struct MergedPrefs {
|
|
voice: Option<String>,
|
|
library: Option<String>,
|
|
display_tz: Option<String>,
|
|
default_model: Option<String>,
|
|
default_backend: Option<String>,
|
|
default_persona_id: Option<String>,
|
|
reel_span: Option<String>,
|
|
reel_max_segments: Option<i32>,
|
|
}
|
|
|
|
fn merge_patch(existing: Option<&UserAiPrefs>, patch: &UpdatePrefsRequest) -> (MergedPrefs, bool) {
|
|
let merged = MergedPrefs {
|
|
voice: tri_value(&patch.voice, existing, |e| e.voice.clone()),
|
|
library: tri_value(&patch.library, existing, |e| e.library.clone()),
|
|
display_tz: tri_value(&patch.display_tz, existing, |e| e.display_tz.clone()),
|
|
default_model: tri_value(&patch.default_model, existing, |e| e.default_model.clone()),
|
|
default_backend: tri_value(&patch.default_backend, existing, |e| {
|
|
e.default_backend.clone()
|
|
}),
|
|
default_persona_id: tri_value(&patch.default_persona_id, existing, |e| {
|
|
e.default_persona_id.clone()
|
|
}),
|
|
reel_span: tri_value(&patch.reel_span, existing, |e| e.reel_span.clone()),
|
|
reel_max_segments: tri_value(&patch.reel_max_segments, existing, |e| e.reel_max_segments),
|
|
};
|
|
let changed = !matches!(patch.voice, TriState::Absent)
|
|
|| !matches!(patch.library, TriState::Absent)
|
|
|| !matches!(patch.display_tz, TriState::Absent)
|
|
|| !matches!(patch.default_model, TriState::Absent)
|
|
|| !matches!(patch.default_backend, TriState::Absent)
|
|
|| !matches!(patch.default_persona_id, TriState::Absent)
|
|
|| !matches!(patch.reel_span, TriState::Absent)
|
|
|| !matches!(patch.reel_max_segments, TriState::Absent);
|
|
(merged, 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(),
|
|
default_model: row.default_model.clone(),
|
|
default_backend: row.default_backend.clone(),
|
|
default_persona_id: row.default_persona_id.clone(),
|
|
reel_span: row.reel_span.clone(),
|
|
reel_max_segments: row.reel_max_segments,
|
|
updated_at: Some(row.updated_at),
|
|
},
|
|
None => PrefsResponse {
|
|
voice: None,
|
|
library: None,
|
|
display_tz: None,
|
|
default_model: None,
|
|
default_backend: None,
|
|
default_persona_id: None,
|
|
reel_span: None,
|
|
reel_max_segments: 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() {
|
|
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 value → 400, nothing written
|
|
// (not even the other fields in the same body).
|
|
if let TriState::Val(name) = &patch.display_tz
|
|
&& !is_valid_display_tz(name)
|
|
{
|
|
return HttpResponse::BadRequest().json(json!({
|
|
"error": format!("invalid display_tz: {name}")
|
|
}));
|
|
}
|
|
if let TriState::Val(name) = &patch.default_backend
|
|
&& !is_valid_backend(name)
|
|
{
|
|
return HttpResponse::BadRequest().json(json!({
|
|
"error": format!("invalid default_backend: {name} (expected local|hybrid)")
|
|
}));
|
|
}
|
|
if let TriState::Val(name) = &patch.reel_span
|
|
&& MemoriesSpan::from_str(name).is_err()
|
|
{
|
|
return HttpResponse::BadRequest().json(json!({
|
|
"error": format!("invalid reel_span: {name} (expected day|week|month)")
|
|
}));
|
|
}
|
|
|
|
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 existing = match dao.get_prefs() {
|
|
Ok(row) => row,
|
|
Err(e) => {
|
|
return HttpResponse::InternalServerError().json(json!({
|
|
"error": format!("failed to read prefs: {e}")
|
|
}));
|
|
}
|
|
};
|
|
|
|
let (merged, 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: merged.voice.clone(),
|
|
library: merged.library.clone(),
|
|
display_tz: merged.display_tz.clone(),
|
|
default_model: merged.default_model.clone(),
|
|
default_backend: merged.default_backend.clone(),
|
|
default_persona_id: merged.default_persona_id.clone(),
|
|
reel_span: merged.reel_span.clone(),
|
|
reel_max_segments: merged.reel_max_segments,
|
|
updated_at: Some(now),
|
|
};
|
|
let upsert = UpsertUserAiPrefs {
|
|
voice: merged.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: merged.library,
|
|
display_tz: merged.display_tz,
|
|
default_model: merged.default_model,
|
|
default_backend: merged.default_backend,
|
|
default_persona_id: merged.default_persona_id,
|
|
reel_span: merged.reel_span,
|
|
reel_max_segments: merged.reel_max_segments,
|
|
updated_at: now,
|
|
};
|
|
match dao.upsert_prefs(&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(&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),
|
|
default_model: None,
|
|
default_backend: None,
|
|
default_persona_id: None,
|
|
reel_span: None,
|
|
reel_max_segments: None,
|
|
}
|
|
}
|
|
|
|
/// A row with owner defaults populated, for the owner-default tests.
|
|
fn seeded_row_with_defaults() -> UpsertUserAiPrefs {
|
|
UpsertUserAiPrefs {
|
|
voice: Some("grandma".to_string()),
|
|
tz_offset_minutes: Some(-240),
|
|
library: Some("1".to_string()),
|
|
updated_at: 1_700_000_000,
|
|
display_tz: Some("America/New_York".to_string()),
|
|
default_model: Some("llama3.2:3b".to_string()),
|
|
default_backend: Some("hybrid".to_string()),
|
|
default_persona_id: Some("cabin".to_string()),
|
|
reel_span: Some("week".to_string()),
|
|
reel_max_segments: Some(24),
|
|
}
|
|
}
|
|
|
|
// --- 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,
|
|
"default_model": null,
|
|
"default_backend": null,
|
|
"default_persona_id": null,
|
|
"reel_span": null,
|
|
"reel_max_segments": 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().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().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().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().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().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().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) --------------------------------------------------
|
|
|
|
fn row_with_defaults() -> UserAiPrefs {
|
|
UserAiPrefs {
|
|
id: 1,
|
|
voice: Some("a".into()),
|
|
tz_offset_minutes: None,
|
|
library: Some("b".into()),
|
|
updated_at: 0,
|
|
display_tz: Some("c".into()),
|
|
default_model: Some("m1".into()),
|
|
default_backend: Some("local".into()),
|
|
default_persona_id: Some("p1".into()),
|
|
reel_span: Some("week".into()),
|
|
reel_max_segments: Some(24),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn merge_patch_omitted_keeps_existing() {
|
|
let row = row_with_defaults();
|
|
let (merged, changed) = merge_patch(Some(&row), &UpdatePrefsRequest::default());
|
|
assert_eq!(merged.voice.as_deref(), Some("a"));
|
|
assert_eq!(merged.library.as_deref(), Some("b"));
|
|
assert_eq!(merged.display_tz.as_deref(), Some("c"));
|
|
assert_eq!(merged.default_model.as_deref(), Some("m1"));
|
|
assert_eq!(merged.default_backend.as_deref(), Some("local"));
|
|
assert_eq!(merged.default_persona_id.as_deref(), Some("p1"));
|
|
assert_eq!(merged.reel_span.as_deref(), Some("week"));
|
|
assert_eq!(merged.reel_max_segments, Some(24));
|
|
assert!(!changed);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_patch_present_field_wins_including_null() {
|
|
let row = row_with_defaults();
|
|
let patch = UpdatePrefsRequest {
|
|
voice: TriState::Null, // clear
|
|
library: TriState::Val(String::from("x")), // set
|
|
display_tz: TriState::Absent,
|
|
default_backend: TriState::Null, // clear an owner default
|
|
reel_span: TriState::Val(String::from("month")),
|
|
reel_max_segments: TriState::Val(12),
|
|
..UpdatePrefsRequest::default()
|
|
};
|
|
let (merged, changed) = merge_patch(Some(&row), &patch);
|
|
assert!(merged.voice.is_none());
|
|
assert_eq!(merged.library.as_deref(), Some("x"));
|
|
// Absent field keeps the stored value.
|
|
assert_eq!(merged.display_tz.as_deref(), Some("c"));
|
|
assert!(merged.default_backend.is_none());
|
|
assert_eq!(merged.reel_span.as_deref(), Some("month"));
|
|
assert_eq!(merged.reel_max_segments, Some(12));
|
|
// Untouched owner defaults survive.
|
|
assert_eq!(merged.default_model.as_deref(), Some("m1"));
|
|
assert_eq!(merged.default_persona_id.as_deref(), Some("p1"));
|
|
assert!(changed);
|
|
}
|
|
|
|
// --- PUT /prefs: owner defaults ------------------------------------------
|
|
|
|
#[actix_rt::test]
|
|
async fn put_prefs_owner_defaults_set_and_persist() {
|
|
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!({
|
|
"default_model": "llama3.2:3b",
|
|
"default_backend": "hybrid",
|
|
"default_persona_id": "cabin",
|
|
"reel_span": "week",
|
|
"reel_max_segments": 24
|
|
}))
|
|
.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().unwrap().unwrap();
|
|
assert_eq!(row.default_model.as_deref(), Some("llama3.2:3b"));
|
|
assert_eq!(row.default_backend.as_deref(), Some("hybrid"));
|
|
assert_eq!(row.default_persona_id.as_deref(), Some("cabin"));
|
|
assert_eq!(row.reel_span.as_deref(), Some("week"));
|
|
assert_eq!(row.reel_max_segments, Some(24));
|
|
// Mirror fields untouched by the owner-default patch.
|
|
assert_eq!(row.voice.as_deref(), Some("grandma"));
|
|
assert_eq!(row.library.as_deref(), Some("1"));
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn put_prefs_owner_defaults_null_clears() {
|
|
let (state, mut seed_dao) = prefs_state();
|
|
seed(&mut seed_dao, seeded_row_with_defaults());
|
|
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!({
|
|
"default_model": null,
|
|
"default_backend": null,
|
|
"default_persona_id": null,
|
|
"reel_span": null,
|
|
"reel_max_segments": 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().unwrap().unwrap();
|
|
assert!(row.default_model.is_none());
|
|
assert!(row.default_backend.is_none());
|
|
assert!(row.default_persona_id.is_none());
|
|
assert!(row.reel_span.is_none());
|
|
assert!(row.reel_max_segments.is_none());
|
|
// Sibling fields survive a clear patch.
|
|
assert_eq!(row.voice.as_deref(), Some("grandma"));
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn put_prefs_invalid_backend_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!({"default_backend": "quantum", "voice": "dad"}))
|
|
.to_request();
|
|
let resp = call_service(&app, req).await;
|
|
assert_eq!(resp.status(), 400);
|
|
|
|
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
|
|
let row = dao.get_prefs().unwrap().unwrap();
|
|
// Nothing written — not even the valid field in the same body.
|
|
assert_eq!(row.voice.as_deref(), Some("grandma"));
|
|
assert!(row.default_backend.is_none());
|
|
assert_eq!(row.updated_at, 1_700_000_000);
|
|
}
|
|
|
|
#[test]
|
|
fn backend_validator_delegates_to_canonical_parser() {
|
|
// Case-insensitive like BackendKind::parse (a stored pref must always
|
|
// be consumable downstream), but the blank string the parser tolerates
|
|
// for request fields is rejected — NULL already means "no default".
|
|
assert!(is_valid_backend("local"));
|
|
assert!(is_valid_backend("hybrid"));
|
|
assert!(is_valid_backend("Hybrid"));
|
|
assert!(is_valid_backend(" LOCAL "));
|
|
assert!(!is_valid_backend("quantum"));
|
|
assert!(!is_valid_backend(""));
|
|
assert!(!is_valid_backend(" "));
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn put_prefs_invalid_reel_span_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!({"reel_span": "decade"}))
|
|
.to_request();
|
|
let resp = call_service(&app, req).await;
|
|
assert_eq!(resp.status(), 400);
|
|
|
|
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
|
|
let row = dao.get_prefs().unwrap().unwrap();
|
|
assert!(row.reel_span.is_none());
|
|
assert_eq!(row.updated_at, 1_700_000_000);
|
|
}
|
|
}
|