Refactor insights handling and error encoding in presentation layer
- Updated insights analyzer tests to use Utc for date handling. - Added timezone support for insights retrieval, allowing insights to be bucketed by the user's local time. - Introduced `encode_domain_error` for better error handling in API responses. - Modified various handlers to utilize the new error encoding function. - Enhanced insights API to reject unknown time zones and return appropriate errors. - Updated frontend components to handle insights data with timezone awareness. - Improved localization for insights sample counts.
This commit is contained in:
@@ -30,7 +30,7 @@ impl InsightsQuery for PostgresInsightsQuery {
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \
|
||||
watch_medium \
|
||||
FROM reviews \
|
||||
WHERE user_id = $1 \
|
||||
WHERE user_id = $1 AND remote_actor_url IS NULL \
|
||||
ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
|
||||
@@ -8,8 +8,6 @@ use sqlx::{Row, SqlitePool};
|
||||
|
||||
use adapter_common::{parse_datetime, parse_uuid};
|
||||
|
||||
use crate::wrapup::fetch_genres_sqlite;
|
||||
|
||||
/// SQLite read side of Insights (ADR-0010). Deliberately thin: one query for
|
||||
/// review basics, one for genres — every threshold and comparison lives in
|
||||
/// the pure analyzer, not here.
|
||||
@@ -29,7 +27,7 @@ impl InsightsQuery for SqliteInsightsQuery {
|
||||
let rows = sqlx::query(
|
||||
"SELECT movie_id, rating, watched_at, watch_medium \
|
||||
FROM reviews \
|
||||
WHERE user_id = ? \
|
||||
WHERE user_id = ? AND remote_actor_url IS NULL \
|
||||
ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
@@ -37,20 +35,32 @@ impl InsightsQuery for SqliteInsightsQuery {
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let mut movie_ids: Vec<String> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for row in &rows {
|
||||
// Subquery rather than an `IN (?, …)` bind list: a whole diary can
|
||||
// exceed SQLite's bound-parameter limit.
|
||||
let genre_rows = sqlx::query(
|
||||
"SELECT movie_id, name FROM movie_genres \
|
||||
WHERE movie_id IN ( \
|
||||
SELECT movie_id FROM reviews \
|
||||
WHERE user_id = ? AND remote_actor_url IS NULL \
|
||||
) \
|
||||
ORDER BY name",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let mut genres_map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for row in genre_rows {
|
||||
let mid: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
if seen.insert(mid.clone()) {
|
||||
movie_ids.push(mid);
|
||||
}
|
||||
let name: String = row
|
||||
.try_get("name")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
genres_map.entry(mid).or_default().push(name);
|
||||
}
|
||||
|
||||
let genres_map: HashMap<String, Vec<String>> =
|
||||
fetch_genres_sqlite(&self.pool, &movie_ids).await?;
|
||||
|
||||
let mut result = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
let movie_id: String = row
|
||||
|
||||
@@ -17,7 +17,7 @@ pub enum InsightDto {
|
||||
sample_count: u32,
|
||||
},
|
||||
/// `genre`'s share of watches in `month` (1–12) is `multiplier` times the
|
||||
/// uniform 1/12 baseline.
|
||||
/// user's own share of watches in that month.
|
||||
Seasonality {
|
||||
genre: String,
|
||||
month: u32,
|
||||
@@ -47,3 +47,11 @@ pub enum InsightDto {
|
||||
pub struct InsightsResponse {
|
||||
pub insights: Vec<InsightDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct InsightsQueryParams {
|
||||
/// IANA time zone (e.g. `Europe/Warsaw`) months, weekdays and hours are
|
||||
/// bucketed in. Defaults to UTC.
|
||||
pub tz: Option<String>,
|
||||
}
|
||||
|
||||
@@ -3,13 +3,20 @@
|
||||
//! thresholds. No caching, no snapshots — the same on-the-fly shape as the
|
||||
//! existing live stats.
|
||||
|
||||
use chrono::TimeZone;
|
||||
use domain::{errors::DomainError, models::Insight, value_objects::UserId};
|
||||
|
||||
use crate::insights::deps::GetInsightsDeps;
|
||||
|
||||
pub async fn execute(deps: &GetInsightsDeps, user_id: UserId) -> Result<Vec<Insight>, DomainError> {
|
||||
pub async fn execute<Tz: TimeZone + Sync>(
|
||||
deps: &GetInsightsDeps,
|
||||
user_id: UserId,
|
||||
tz: &Tz,
|
||||
) -> Result<Vec<Insight>, DomainError> {
|
||||
let rows = deps.insights.get_watch_rows(&user_id).await?;
|
||||
Ok(domain::services::insights_analyzer::build_insights(&rows))
|
||||
Ok(domain::services::insights_analyzer::build_insights(
|
||||
&rows, tz,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -31,7 +31,9 @@ async fn returns_analyzer_output_for_the_user() {
|
||||
insights: InMemoryInsightsQuery::with_rows(user, rows),
|
||||
};
|
||||
|
||||
let insights = execute(&deps, UserId::from_uuid(user)).await.unwrap();
|
||||
let insights = execute(&deps, UserId::from_uuid(user), &chrono::Utc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(insights.iter().any(
|
||||
|i| matches!(i, domain::models::Insight::RatingLift { genre, .. } if genre == "Thriller")
|
||||
));
|
||||
@@ -49,7 +51,9 @@ async fn other_users_rows_are_not_seen() {
|
||||
insights: InMemoryInsightsQuery::with_rows(other, rows),
|
||||
};
|
||||
|
||||
let insights = execute(&deps, UserId::from_uuid(user)).await.unwrap();
|
||||
let insights = execute(&deps, UserId::from_uuid(user), &chrono::Utc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(insights.is_empty());
|
||||
}
|
||||
|
||||
@@ -60,7 +64,9 @@ async fn empty_diary_returns_empty_list() {
|
||||
insights: InMemoryInsightsQuery::new(),
|
||||
};
|
||||
|
||||
let insights = execute(&deps, UserId::from_uuid(user)).await.unwrap();
|
||||
let insights = execute(&deps, UserId::from_uuid(user), &chrono::Utc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(insights.is_empty());
|
||||
}
|
||||
|
||||
@@ -83,7 +89,7 @@ async fn port_errors_propagate() {
|
||||
insights: Arc::new(Failing),
|
||||
};
|
||||
|
||||
let err = execute(&deps, UserId::from_uuid(Uuid::new_v4()))
|
||||
let err = execute(&deps, UserId::from_uuid(Uuid::new_v4()), &chrono::Utc)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
|
||||
@@ -35,7 +35,7 @@ pub enum Insight {
|
||||
sample_count: u32,
|
||||
},
|
||||
/// The genre's share of watches in one month of the year is `multiplier`
|
||||
/// times the uniform 1/12 baseline.
|
||||
/// times the user's own share of watches in that month.
|
||||
Seasonality {
|
||||
genre: String,
|
||||
/// 1–12
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{Datelike, Timelike, Weekday};
|
||||
use chrono::{Datelike, NaiveDateTime, TimeZone, Timelike, Weekday};
|
||||
|
||||
use crate::models::{Insight, WatchRow};
|
||||
|
||||
@@ -21,8 +21,11 @@ const MIN_BASELINE_RATINGS: usize = 10;
|
||||
const MIN_RATING_DELTA: f64 = 0.3;
|
||||
/// Rows a genre needs before month-of-year distribution is more than noise.
|
||||
const MIN_GENRE_SEASON_ROWS: usize = 15;
|
||||
/// Minimum lift over the uniform 1/12 month baseline.
|
||||
/// Minimum lift over the user's own share of watches in that month.
|
||||
const MIN_SEASON_MULTIPLIER: f64 = 1.5;
|
||||
/// Genre watches the peak month needs — a lift built on one or two rows in a
|
||||
/// quiet month is noise, however large the ratio.
|
||||
const MIN_SEASON_PEAK_ROWS: u32 = 4;
|
||||
/// Rows a medium needs before its weekend share means anything.
|
||||
const MIN_MEDIUM_ROWS: usize = 5;
|
||||
/// Minimum lift over the user's own weekend share.
|
||||
@@ -40,15 +43,21 @@ const MAX_PER_CATEGORY: usize = 3;
|
||||
/// Width of the peak-hour window in the rhythm insight.
|
||||
const RHYTHM_WINDOW_HOURS: u32 = 3;
|
||||
|
||||
pub fn build_insights(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
/// `rows` carry `watched_at` in UTC as stored; `tz` is the viewer's zone, so
|
||||
/// months, weekdays and hours are bucketed as the user lived them.
|
||||
pub fn build_insights<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
|
||||
let mut insights = Vec::new();
|
||||
insights.extend(rating_lifts(rows));
|
||||
insights.extend(seasonalities(rows));
|
||||
insights.extend(medium_timings(rows));
|
||||
insights.extend(rhythm(rows));
|
||||
insights.extend(seasonalities(rows, tz));
|
||||
insights.extend(medium_timings(rows, tz));
|
||||
insights.extend(rhythm(rows, tz));
|
||||
insights
|
||||
}
|
||||
|
||||
fn local<Tz: TimeZone>(tz: &Tz, utc: &NaiveDateTime) -> NaiveDateTime {
|
||||
tz.from_utc_datetime(utc).naive_local()
|
||||
}
|
||||
|
||||
// ── Rating lift ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn rating_lifts(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
@@ -75,20 +84,13 @@ fn rating_lifts(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
if delta.abs() < MIN_RATING_DELTA {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
strength(delta.abs(), ratings.len()),
|
||||
genre,
|
||||
genre_avg,
|
||||
Some(Insight::RatingLift {
|
||||
genre: genre.to_string(),
|
||||
genre_avg: round2(genre_avg),
|
||||
user_avg: round2(user_avg),
|
||||
delta,
|
||||
ratings.len(),
|
||||
))
|
||||
})
|
||||
.map(|(_, genre, genre_avg, delta, n)| Insight::RatingLift {
|
||||
genre: genre.to_string(),
|
||||
genre_avg: round2(genre_avg),
|
||||
user_avg: round2(user_avg),
|
||||
delta,
|
||||
sample_count: n as u32,
|
||||
sample_count: ratings.len() as u32,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -128,50 +130,55 @@ fn avg_rating(rows: &[&WatchRow]) -> f64 {
|
||||
|
||||
// ── Seasonality ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn seasonalities(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
let mut by_genre: HashMap<&str, Vec<u32>> = HashMap::new();
|
||||
fn seasonalities<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
|
||||
if rows.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// The user's own month-of-year distribution is the baseline: a diary
|
||||
// started in October, or a user who simply watches more in winter, must
|
||||
// not make every big genre look seasonal.
|
||||
let mut user_months = [0u32; 12];
|
||||
let mut by_genre: HashMap<&str, [u32; 12]> = HashMap::new();
|
||||
for row in rows {
|
||||
let m = local(tz, &row.watched_at).month0() as usize;
|
||||
user_months[m] += 1;
|
||||
for genre in &row.genres {
|
||||
by_genre
|
||||
.entry(genre)
|
||||
.or_default()
|
||||
.push(row.watched_at.month());
|
||||
by_genre.entry(genre).or_insert([0; 12])[m] += 1;
|
||||
}
|
||||
}
|
||||
let user_total = rows.len() as f64;
|
||||
|
||||
let mut candidates: Vec<Insight> = by_genre
|
||||
.into_iter()
|
||||
.filter(|(_, months)| months.len() >= MIN_GENRE_SEASON_ROWS)
|
||||
.filter_map(|(genre, months)| {
|
||||
let total = months.len() as f64;
|
||||
let mut histogram = [0u32; 12];
|
||||
for &m in &months {
|
||||
histogram[(m - 1) as usize] += 1;
|
||||
.filter_map(|(genre, histogram)| {
|
||||
let genre_total: u32 = histogram.iter().sum();
|
||||
if (genre_total as usize) < MIN_GENRE_SEASON_ROWS {
|
||||
return None;
|
||||
}
|
||||
// Smallest month number wins ties — deterministic output.
|
||||
let (peak_month, peak_count) = histogram
|
||||
.into_iter()
|
||||
// Month with the largest lift over the user's share for that
|
||||
// month; smallest month number wins ties — deterministic output.
|
||||
let (peak_month, multiplier) = histogram
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(i, c)| (*c, std::cmp::Reverse(*i)))
|
||||
.map(|(i, c)| (i as u32 + 1, c))?;
|
||||
// Baseline is a uniform year: each month expects total/12 watches.
|
||||
let multiplier = round2(f64::from(peak_count) * 12.0 / total);
|
||||
.filter(|(_, c)| **c >= MIN_SEASON_PEAK_ROWS)
|
||||
.map(|(i, &c)| {
|
||||
let genre_share = f64::from(c) / f64::from(genre_total);
|
||||
let user_share = f64::from(user_months[i]) / user_total;
|
||||
(i as u32 + 1, round2(genre_share / user_share))
|
||||
})
|
||||
.fold(None, |best: Option<(u32, f64)>, cur| match best {
|
||||
Some(b) if b.1 >= cur.1 => Some(b),
|
||||
_ => Some(cur),
|
||||
})?;
|
||||
if multiplier < MIN_SEASON_MULTIPLIER {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
strength(multiplier - 1.0, months.len()),
|
||||
genre,
|
||||
peak_month,
|
||||
Some(Insight::Seasonality {
|
||||
genre: genre.to_string(),
|
||||
month: peak_month,
|
||||
multiplier,
|
||||
months.len(),
|
||||
))
|
||||
})
|
||||
.map(|(_, genre, month, multiplier, n)| Insight::Seasonality {
|
||||
genre: genre.to_string(),
|
||||
month,
|
||||
multiplier,
|
||||
sample_count: n as u32,
|
||||
sample_count: genre_total,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -182,7 +189,7 @@ fn seasonalities(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
|
||||
// ── Medium timing ────────────────────────────────────────────────────────────
|
||||
|
||||
fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
fn medium_timings<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
|
||||
if rows.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
@@ -196,7 +203,10 @@ fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
let mut by_medium: HashMap<&str, (usize, usize)> = HashMap::new();
|
||||
let mut weekend_total = 0usize;
|
||||
for row in rows.iter().filter(|r| r.watch_medium.is_some()) {
|
||||
let weekend = matches!(row.watched_at.weekday(), Weekday::Sat | Weekday::Sun);
|
||||
let weekend = matches!(
|
||||
local(tz, &row.watched_at).weekday(),
|
||||
Weekday::Sat | Weekday::Sun
|
||||
);
|
||||
weekend_total += usize::from(weekend);
|
||||
let entry = by_medium
|
||||
.entry(row.watch_medium.as_deref().expect("filtered above"))
|
||||
@@ -220,23 +230,14 @@ fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
if multiplier < MIN_MEDIUM_MULTIPLIER {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
strength(multiplier - 1.0, count),
|
||||
medium,
|
||||
weekend_share,
|
||||
multiplier,
|
||||
count,
|
||||
))
|
||||
})
|
||||
.map(
|
||||
|(_, medium, weekend_share, multiplier, n)| Insight::MediumTiming {
|
||||
Some(Insight::MediumTiming {
|
||||
medium: medium.to_string(),
|
||||
multiplier,
|
||||
weekend_share: round2(weekend_share),
|
||||
baseline_share: round2(baseline_share),
|
||||
sample_count: n as u32,
|
||||
},
|
||||
)
|
||||
sample_count: count as u32,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(strongest_first);
|
||||
@@ -246,10 +247,14 @@ fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
|
||||
// ── Rhythm ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn rhythm(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
let real: Vec<&WatchRow> = rows
|
||||
fn rhythm<Tz: TimeZone>(rows: &[WatchRow], tz: &Tz) -> Vec<Insight> {
|
||||
// Import noise is detected on the stored value: date-only imports land at
|
||||
// exactly 00:00 as stored, which no longer reads as midnight once shifted
|
||||
// into the viewer's zone.
|
||||
let real: Vec<NaiveDateTime> = rows
|
||||
.iter()
|
||||
.filter(|r| !(r.watched_at.hour() == 0 && r.watched_at.minute() == 0))
|
||||
.map(|r| local(tz, &r.watched_at))
|
||||
.collect();
|
||||
let midnight_share = 1.0 - real.len() as f64 / rows.len().max(1) as f64;
|
||||
if rows.is_empty() || real.len() < MIN_RHYTHM_ROWS || midnight_share > MAX_MIDNIGHT_SHARE {
|
||||
@@ -258,9 +263,9 @@ fn rhythm(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
|
||||
let mut hours = [0u32; 24];
|
||||
let mut days = [0u32; 7]; // index 0 = Monday (chrono weekday number − 1)
|
||||
for row in &real {
|
||||
hours[row.watched_at.hour() as usize] += 1;
|
||||
days[row.watched_at.weekday().number_from_monday() as usize - 1] += 1;
|
||||
for at in &real {
|
||||
hours[at.hour() as usize] += 1;
|
||||
days[at.weekday().number_from_monday() as usize - 1] += 1;
|
||||
}
|
||||
|
||||
// Circular 3-hour window with the most watches; smallest start hour wins ties.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chrono::{Datelike, NaiveDate, Weekday};
|
||||
use chrono::{Datelike, FixedOffset, NaiveDate, Utc, Weekday};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Insight, WatchRow};
|
||||
@@ -41,7 +41,7 @@ fn rating_lift_compares_genre_to_personal_average() {
|
||||
.collect();
|
||||
rows.extend((0..12).map(|i| watch(&["Comedy"], 2, date(2024, 3, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let thrill = insights.iter().find_map(|i| match i {
|
||||
Insight::RatingLift {
|
||||
genre,
|
||||
@@ -81,7 +81,7 @@ fn rating_lift_ignores_rewatches() {
|
||||
rows.push(rewatch);
|
||||
}
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let thrill = insights.iter().find_map(|i| match i {
|
||||
Insight::RatingLift {
|
||||
genre,
|
||||
@@ -107,7 +107,7 @@ fn rating_lift_below_sample_threshold_is_absent() {
|
||||
// high they are rated, the genre must not yield an insight.
|
||||
rows.extend((0..4).map(|i| watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
assert!(
|
||||
!insights
|
||||
.iter()
|
||||
@@ -126,14 +126,14 @@ fn rating_lift_below_delta_threshold_is_absent() {
|
||||
rows.extend((0..3).map(|i| watch(&["Drama"], 3, date(2024, 1, 13 + i), 20)));
|
||||
rows.extend((0..5).map(|i| watch(&["Thriller"], 4, date(2024, 2, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
assert!(!kinds(&insights).contains(&"rating_lift"));
|
||||
}
|
||||
|
||||
// ── Seasonality ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn seasonality_detects_month_peak_against_uniform_baseline() {
|
||||
fn seasonality_detects_month_peak_against_own_monthly_baseline() {
|
||||
// 16 horrors: 10 in October across two years, 6 scattered.
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
for i in 0..10u32 {
|
||||
@@ -146,7 +146,7 @@ fn seasonality_detects_month_peak_against_uniform_baseline() {
|
||||
// A perfectly spread genre (2 watches per month) must NOT appear.
|
||||
rows.extend((0..24).map(|i| watch(&["Drama"], 3, date(2024, 1 + i % 12, 5), 21)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let horror = insights.iter().find_map(|i| match i {
|
||||
Insight::Seasonality {
|
||||
genre,
|
||||
@@ -159,9 +159,10 @@ fn seasonality_detects_month_peak_against_uniform_baseline() {
|
||||
});
|
||||
let (month, multiplier, count) = horror.expect("Horror seasonality expected");
|
||||
assert_eq!(month, 10);
|
||||
// 10 of 16 watches in one month → (10/16)·12 = 7.5× the uniform baseline.
|
||||
// Horror: 10 of 16 watches in October (62.5%); the whole diary puts
|
||||
// 12 of 40 watches there (30%) → 0.625 / 0.3 ≈ 2.08× the user's own share.
|
||||
assert!(
|
||||
(multiplier - 7.5).abs() < 1e-9,
|
||||
(multiplier - 2.08).abs() < 1e-9,
|
||||
"multiplier was {multiplier}"
|
||||
);
|
||||
assert_eq!(count, 16);
|
||||
@@ -174,12 +175,26 @@ fn seasonality_detects_month_peak_against_uniform_baseline() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seasonality_ignores_when_the_diary_itself_is_concentrated() {
|
||||
// A diary started in October: every genre peaks in Oct/Nov only because
|
||||
// the user logged nothing else. Relative to their own months, nothing is
|
||||
// seasonal.
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
for i in 0..20u32 {
|
||||
let month = if i % 2 == 0 { 10 } else { 11 };
|
||||
rows.push(watch(&["Drama"], 3, date(2024, month, 1 + i), 21));
|
||||
rows.push(watch(&["Comedy"], 3, date(2024, month, 1 + i), 21));
|
||||
}
|
||||
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"seasonality"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seasonality_below_sample_threshold_is_absent() {
|
||||
let rows: Vec<WatchRow> = (0..14)
|
||||
.map(|i| watch(&["Horror"], 4, date(2024, 10, 1 + i), 21))
|
||||
.collect();
|
||||
assert!(!kinds(&build_insights(&rows)).contains(&"seasonality"));
|
||||
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"seasonality"));
|
||||
}
|
||||
|
||||
// ── Medium timing ────────────────────────────────────────────────────────────
|
||||
@@ -216,7 +231,7 @@ fn medium_timing_detects_weekend_lift() {
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let cinema = insights.iter().find_map(|i| match i {
|
||||
Insight::MediumTiming {
|
||||
medium,
|
||||
@@ -255,7 +270,7 @@ fn medium_timing_skipped_when_medium_mostly_missing() {
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
assert!(!kinds(&build_insights(&rows)).contains(&"medium_timing"));
|
||||
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"medium_timing"));
|
||||
}
|
||||
|
||||
// ── Rhythm ───────────────────────────────────────────────────────────────────
|
||||
@@ -280,7 +295,7 @@ fn rhythm_finds_peak_window_and_days() {
|
||||
}
|
||||
}
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let rhythm = insights.iter().find_map(|i| match i {
|
||||
Insight::Rhythm {
|
||||
peak_hour_start,
|
||||
@@ -305,6 +320,61 @@ fn rhythm_finds_peak_window_and_days() {
|
||||
assert_eq!(count, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rhythm_buckets_in_the_viewers_time_zone() {
|
||||
// 12 watches stored at 22:30 UTC on Tue/Wed/Thu are 00:30 the next day
|
||||
// for a viewer at UTC+2.
|
||||
let first_tuesday = date(2024, 3, 5);
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
for day in 0..3u32 {
|
||||
for week in 0..(5 - day) {
|
||||
rows.push(watch(
|
||||
&["Drama"],
|
||||
4,
|
||||
first_tuesday
|
||||
+ chrono::Duration::days(i64::from(day))
|
||||
+ chrono::Duration::weeks(i64::from(week)),
|
||||
22,
|
||||
));
|
||||
}
|
||||
}
|
||||
let plus_two = FixedOffset::east_opt(2 * 3600).unwrap();
|
||||
|
||||
let insights = build_insights(&rows, &plus_two);
|
||||
let (start, end, days) = insights
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Insight::Rhythm {
|
||||
peak_hour_start,
|
||||
peak_hour_end,
|
||||
top_days,
|
||||
..
|
||||
} => Some((*peak_hour_start, *peak_hour_end, top_days.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.expect("rhythm expected");
|
||||
// Local hour 0; the scan starts at 0, so the first window covering it is 0–3.
|
||||
assert_eq!((start, end), (0, 3));
|
||||
assert_eq!(days, vec![3, 4, 5], "Wed=3, Thu=4, Fri=5 locally");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rhythm_import_noise_detected_before_zone_shift() {
|
||||
// Date-only imports are stored at 00:00 UTC; at UTC+2 they read 02:00,
|
||||
// but they are still noise.
|
||||
let rows: Vec<WatchRow> = (0..12u32)
|
||||
.map(|i| WatchRow {
|
||||
movie_id: Uuid::new_v4(),
|
||||
rating: 4,
|
||||
watched_at: date(2024, 1, 1 + i).and_hms_opt(0, 0, 0).unwrap(),
|
||||
watch_medium: None,
|
||||
genres: vec!["Drama".to_string()],
|
||||
})
|
||||
.collect();
|
||||
let plus_two = FixedOffset::east_opt(2 * 3600).unwrap();
|
||||
assert!(!kinds(&build_insights(&rows, &plus_two)).contains(&"rhythm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rhythm_skipped_when_timestamps_are_import_noise() {
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
@@ -320,14 +390,14 @@ fn rhythm_skipped_when_timestamps_are_import_noise() {
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
assert!(!kinds(&build_insights(&rows)).contains(&"rhythm"));
|
||||
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"rhythm"));
|
||||
}
|
||||
|
||||
// ── Overall ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn empty_diary_yields_nothing() {
|
||||
assert!(build_insights(&[]).is_empty());
|
||||
assert!(build_insights(&[], &Utc).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -335,7 +405,7 @@ fn small_diary_yields_nothing() {
|
||||
let rows: Vec<WatchRow> = (0..4)
|
||||
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i), 20))
|
||||
.collect();
|
||||
assert!(build_insights(&rows).is_empty());
|
||||
assert!(build_insights(&rows, &Utc).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -356,7 +426,7 @@ fn max_three_insights_per_category() {
|
||||
// Baseline dramas rated 1 make every lift large.
|
||||
rows.extend((0..10).map(|i| watch(&["Drama"], 1, date(2024, 3, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let insights = build_insights(&rows, &Utc);
|
||||
let rating_lifts = insights
|
||||
.iter()
|
||||
.filter(|i| matches!(i, Insight::RatingLift { .. }))
|
||||
|
||||
@@ -26,6 +26,7 @@ tokio = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
chrono-tz = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use api_types::{
|
||||
};
|
||||
use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
|
||||
|
||||
use super::helpers::{build_export_response, build_page_context, encode_error};
|
||||
use super::helpers::{build_export_response, build_page_context, encode_domain_error};
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -238,7 +238,7 @@ pub async fn post_review_html(
|
||||
{
|
||||
Ok(_) => Redirect::to("/").into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!("/reviews/new?error={}", msg)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,19 @@ pub(crate) fn encode_error(msg: &str) -> String {
|
||||
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
/// Error text for a classic-page `?error=` redirect. Infrastructure failures
|
||||
/// are logged here — the redirect is their only exit, so nothing else would —
|
||||
/// and shown to the user generically, never with internal detail.
|
||||
pub(crate) fn encode_domain_error(e: &domain::errors::DomainError) -> String {
|
||||
match e {
|
||||
domain::errors::DomainError::InfrastructureError(_) => {
|
||||
tracing::error!("Internal error: {e}");
|
||||
encode_error("Something went wrong, please try again")
|
||||
}
|
||||
_ => encode_error(&e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_export_response(
|
||||
format_str: &str,
|
||||
user_id: uuid::Uuid,
|
||||
|
||||
@@ -40,7 +40,7 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::helpers::encode_error;
|
||||
use super::helpers::encode_domain_error;
|
||||
|
||||
fn str_to_domain_field(field: &str) -> Option<DomainField> {
|
||||
match field {
|
||||
@@ -171,7 +171,7 @@ pub async fn post_upload(
|
||||
{
|
||||
Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))).into_response()
|
||||
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,7 +258,7 @@ pub async fn post_mapping(
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/import/{}/mapping?error={}",
|
||||
session_id_str,
|
||||
encode_error(&e.to_string())
|
||||
encode_domain_error(&e)
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
@@ -375,7 +375,7 @@ pub async fn post_confirm(
|
||||
))
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))).into_response()
|
||||
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use axum::{Json, extract::State};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
};
|
||||
|
||||
use application::insights::get as get_insights_uc;
|
||||
use domain::models::Insight;
|
||||
use domain::{errors::DomainError, models::Insight};
|
||||
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::insights::{InsightDto, InsightsResponse};
|
||||
use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse};
|
||||
|
||||
fn to_dto(insight: Insight) -> InsightDto {
|
||||
match insight {
|
||||
@@ -64,8 +67,10 @@ fn to_dto(insight: Insight) -> InsightDto {
|
||||
/// statistics already public on profiles (ADR-0010).
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/insights",
|
||||
params(InsightsQueryParams),
|
||||
responses(
|
||||
(status = 200, body = InsightsResponse),
|
||||
(status = 400, description = "Unknown time zone"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
@@ -73,8 +78,17 @@ fn to_dto(insight: Insight) -> InsightDto {
|
||||
pub async fn get_insights(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<InsightsQueryParams>,
|
||||
) -> Result<Json<InsightsResponse>, ApiError> {
|
||||
let insights = get_insights_uc::execute(&state.app_ctx.deps.insights.get, user.0).await?;
|
||||
let tz: chrono_tz::Tz = match params.tz.as_deref() {
|
||||
None => chrono_tz::UTC,
|
||||
Some(name) => name.parse().map_err(|_| {
|
||||
ApiError(DomainError::ValidationError(format!(
|
||||
"unknown time zone: {name}"
|
||||
)))
|
||||
})?,
|
||||
};
|
||||
let insights = get_insights_uc::execute(&state.app_ctx.deps.insights.get, user.0, &tz).await?;
|
||||
Ok(Json(InsightsResponse {
|
||||
insights: insights.into_iter().map(to_dto).collect(),
|
||||
}))
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::{
|
||||
};
|
||||
use template_askama::{IntegrationsTemplate, WatchQueueTemplate};
|
||||
|
||||
use super::helpers::{build_page_context, encode_error};
|
||||
use super::helpers::{build_page_context, encode_domain_error};
|
||||
|
||||
// ── HTML ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -184,7 +184,7 @@ pub async fn post_confirm_single(
|
||||
{
|
||||
Ok(_) => Redirect::to("/watch-queue").into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!("/watch-queue?error={msg}")).into_response()
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,7 @@ pub async fn post_dismiss_single(
|
||||
{
|
||||
Ok(_) => Redirect::to("/watch-queue").into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!("/watch-queue?error={msg}")).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialI
|
||||
use template_askama::{BlockedActorsTemplate, BlockedDomainsTemplate};
|
||||
use template_askama::{FollowersTemplate, FollowingTemplate, RemoteActorData};
|
||||
|
||||
use super::helpers::{build_page_context, encode_error};
|
||||
use super::helpers::{build_page_context, encode_domain_error};
|
||||
|
||||
fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorDto {
|
||||
RemoteActorDto {
|
||||
@@ -576,7 +576,7 @@ pub async fn follow_user(
|
||||
Ok(()) => Redirect::to(&redirect_base).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("follow error: {:?}", e);
|
||||
let msg = encode_error(&e.to_string());
|
||||
let msg = encode_domain_error(&e);
|
||||
let sep = if redirect_base.contains('?') {
|
||||
'&'
|
||||
} else {
|
||||
@@ -614,7 +614,7 @@ pub async fn unfollow_user(
|
||||
Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!(
|
||||
"/users/{}/following-list?error={}",
|
||||
profile_user_uuid, msg
|
||||
@@ -649,7 +649,7 @@ pub async fn accept_follower_html(
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -680,7 +680,7 @@ pub async fn reject_follower_html(
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -896,7 +896,7 @@ pub async fn remove_follower_html(
|
||||
Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!(
|
||||
"/users/{}/followers-list?error={}",
|
||||
profile_user_uuid, msg
|
||||
|
||||
@@ -829,7 +829,7 @@ pub async fn post_profile_settings(
|
||||
tracing::error!("update_profile error: {:?}", e);
|
||||
return axum::response::Redirect::to(&format!(
|
||||
"/settings/profile?error={}",
|
||||
super::helpers::encode_error(&e.to_string())
|
||||
super::helpers::encode_domain_error(&e)
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
@@ -860,7 +860,7 @@ pub async fn post_profile_settings(
|
||||
tracing::error!("update_profile_fields error: {:?}", e);
|
||||
return axum::response::Redirect::to(&format!(
|
||||
"/settings/profile?error={}",
|
||||
super::helpers::encode_error(&e.to_string())
|
||||
super::helpers::encode_domain_error(&e)
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -313,9 +313,23 @@ async fn create_event_publisher(
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "server=debug,tower_http=debug".into()),
|
||||
))
|
||||
.with(log_filter("server=debug,tower_http=debug"))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
|
||||
/// `RUST_LOG` names the crates to trace in detail; anything it doesn't name
|
||||
/// still reports warnings and errors. Without a baseline, a filter like
|
||||
/// `presentation=debug` silently drops adapter errors (e.g. the sqlx detail
|
||||
/// behind "Database operation failed").
|
||||
fn log_filter(default: &str) -> tracing_subscriber::EnvFilter {
|
||||
let directives = std::env::var("RUST_LOG").unwrap_or_else(|_| default.to_string());
|
||||
let has_baseline = directives
|
||||
.split(',')
|
||||
.any(|d| !d.trim().is_empty() && !d.contains('='));
|
||||
if has_baseline {
|
||||
tracing_subscriber::EnvFilter::new(directives)
|
||||
} else {
|
||||
tracing_subscriber::EnvFilter::new(format!("warn,{directives}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,3 +744,103 @@ async fn get_api_insights_returns_rating_lift_for_own_user() {
|
||||
assert_eq!(rhythm["peak_hour_end"], 14);
|
||||
assert_eq!(rhythm["top_days"], serde_json::json!([5, 7]));
|
||||
}
|
||||
|
||||
async fn get_insights_json(
|
||||
app: Router,
|
||||
user_id: Uuid,
|
||||
query: &str,
|
||||
) -> (StatusCode, serde_json::Value) {
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/insights{query}"))
|
||||
.header("authorization", format!("Bearer {user_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
|
||||
(status, json)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_insights_ignores_remote_reviews() {
|
||||
let (app, pool) = test_app_with(domain::testing::FakeAuthService).await;
|
||||
let user_id = Uuid::new_v4();
|
||||
seed_insights_diary(&pool, user_id).await;
|
||||
// Federated rows stored under the same user_id are someone else's watches.
|
||||
for i in 0..12u32 {
|
||||
let movie_id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
|
||||
.bind(&movie_id)
|
||||
.bind(format!("Horror {i}"))
|
||||
.bind(2019)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
|
||||
.bind(&movie_id)
|
||||
.bind(66600 + i as i64)
|
||||
.bind("Horror")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(&movie_id)
|
||||
.bind(user_id.to_string())
|
||||
.bind(5)
|
||||
.bind("2024-05-05 09:00:00")
|
||||
.bind("2024-05-05 09:00:00")
|
||||
.bind("https://remote.example/users/someone")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (status, json) = get_insights_json(app, user_id, "").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let insights = json["insights"].as_array().unwrap();
|
||||
assert!(
|
||||
!insights.iter().any(|i| i["genre"] == "Horror"),
|
||||
"remote reviews must not feed insights: {json}"
|
||||
);
|
||||
let thriller = insights
|
||||
.iter()
|
||||
.find(|i| i["kind"] == "rating_lift" && i["genre"] == "Thriller")
|
||||
.unwrap();
|
||||
assert_eq!(thriller["delta"], 1.5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_insights_buckets_rhythm_in_requested_time_zone() {
|
||||
let (app, pool) = test_app_with(domain::testing::FakeAuthService).await;
|
||||
let user_id = Uuid::new_v4();
|
||||
seed_insights_diary(&pool, user_id).await;
|
||||
|
||||
// January in Warsaw is UTC+1: 22:30 → 23:30 and 13:00 → 14:00, so the
|
||||
// earliest tied window moves from 11–14 to 12–15.
|
||||
let (status, json) = get_insights_json(app, user_id, "?tz=Europe/Warsaw").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let rhythm = json["insights"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["kind"] == "rhythm")
|
||||
.expect("rhythm expected");
|
||||
assert_eq!(rhythm["peak_hour_start"], 12);
|
||||
assert_eq!(rhythm["peak_hour_end"], 15);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_insights_rejects_unknown_time_zone() {
|
||||
let (app, _pool) = test_app_with(domain::testing::FakeAuthService).await;
|
||||
let (status, _) = get_insights_json(app, Uuid::new_v4(), "?tz=Mars/Olympus").await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
@@ -307,10 +307,24 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = std::env::var("RUST_LOG")
|
||||
.unwrap_or_else(|_| "worker=info,application=info,k_ap=info".to_string());
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(filter))
|
||||
.with(log_filter("worker=info,application=info,k_ap=info"))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
|
||||
/// `RUST_LOG` names the crates to trace in detail; anything it doesn't name
|
||||
/// still reports warnings and errors. Without a baseline, a filter like
|
||||
/// `presentation=debug` silently drops adapter errors (e.g. the sqlx detail
|
||||
/// behind "Database operation failed").
|
||||
fn log_filter(default: &str) -> tracing_subscriber::EnvFilter {
|
||||
let directives = std::env::var("RUST_LOG").unwrap_or_else(|_| default.to_string());
|
||||
let has_baseline = directives
|
||||
.split(',')
|
||||
.any(|d| !d.trim().is_empty() && !d.contains('='));
|
||||
if has_baseline {
|
||||
tracing_subscriber::EnvFilter::new(directives)
|
||||
} else {
|
||||
tracing_subscriber::EnvFilter::new(format!("warn,{directives}"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user