use actix_web::web::Data; use actix_web::{HttpResponse, Responder, get, web}; use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc}; use log::{debug, trace, warn}; use opentelemetry::KeyValue; use opentelemetry::trace::Status; use serde::{Deserialize, Serialize}; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; use std::sync::Mutex; use tracing_opentelemetry::OpenTelemetrySpanExt; use crate::data::Claims; use crate::database::ExifDao; use crate::state::AppState; // Helper that encapsulates path-exclusion semantics #[derive(Debug)] pub struct PathExcluder { base: PathBuf, excluded_dirs: Vec, excluded_patterns: Vec, } impl PathExcluder { /// Build from a `base` path and the raw exclusion entries. /// /// Rules: /// - Entries starting with '/' are interpreted as "absolute under base" /// (e.g. "/photos/private" -> base/photos/private). /// - Entries without '/' are treated as path-component patterns that /// match a directory or file name *under* `base`. The base prefix is /// stripped before matching so a system-level component (e.g. the /// `tmp` in `/tmp/...` when running tests) doesn't masquerade as a /// user-defined exclude. pub fn new(base: &Path, raw_excluded: &[String]) -> Self { let mut excluded_dirs = Vec::new(); let mut excluded_patterns = Vec::new(); for dir in raw_excluded { if let Some(rel) = dir.strip_prefix('/') { // Absolute under base if !rel.is_empty() { excluded_dirs.push(base.join(rel)); } } else { // Pattern anywhere under base excluded_patterns.push(dir.clone()); } } debug!( "PathExcluder created. base={:?}, dirs={:?}, patterns={:?}", base, excluded_dirs, excluded_patterns ); Self { base: base.to_path_buf(), excluded_dirs, excluded_patterns, } } /// Returns true if `path` should be excluded. pub fn is_excluded(&self, path: &Path) -> bool { // Directory-based exclusions for excluded in &self.excluded_dirs { if path.starts_with(excluded) { trace!( "PathExcluder: excluded by dir: {:?} (rule: {:?})", path, excluded ); return true; } } if self.excluded_patterns.is_empty() { return false; } // Strip the base prefix before scanning components. Without this, // every path component above `base` (e.g. `tmp` in `/tmp/test123` // under tempdir, or the user's `home` in `/home/user/Pictures`) // would match user-defined patterns and produce false positives. let scan_root = path.strip_prefix(&self.base).unwrap_or(path); for component in scan_root.components() { if let Some(comp_str) = component.as_os_str().to_str() && self.excluded_patterns.iter().any(|pat| pat == comp_str) { trace!( "PathExcluder: excluded by component pattern: {:?} (component: {:?}, patterns: {:?})", path, comp_str, self.excluded_patterns ); return true; } } false } } #[derive(Copy, Clone, Deserialize, PartialEq, Debug)] #[serde(rename_all = "lowercase")] pub enum MemoriesSpan { Day, Week, Month, } /// Wire/storage string for the span (`"day"` | `"week"` | `"month"`) — the /// same lowercase names as the serde rename, so a stored pref round-trips /// through `MemoriesRequest.span` unchanged. impl std::fmt::Display for MemoriesSpan { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { MemoriesSpan::Day => "day", MemoriesSpan::Week => "week", MemoriesSpan::Month => "month", }) } } impl std::str::FromStr for MemoriesSpan { type Err = String; fn from_str(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { "day" => Ok(MemoriesSpan::Day), "week" => Ok(MemoriesSpan::Week), "month" => Ok(MemoriesSpan::Month), other => Err(format!("invalid span {other:?} (expected day|week|month)")), } } } #[derive(Deserialize)] pub struct MemoriesRequest { pub span: Option, /// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for /// CET). Accepted on the wire for old clients but no longer drives /// grouping (ADR-0001) — grouping uses the owner's display zone. #[allow(dead_code)] pub timezone_offset_minutes: Option, /// Optional library filter. Accepts a library id (e.g. "1") or name /// (e.g. "main"). When omitted, results span all libraries. pub library: Option, } #[derive(Debug, Serialize, Clone)] pub struct MemoryItem { pub path: String, /// True instant (unix seconds) the memory names (ADR-0001) — the /// grouping anchor. For unconverted rows this is *derived* from the /// legacy value under its per-source convention, so it is the real /// moment even while the `backfill_utc_dates` drain is still running. pub created: Option, pub modified: Option, /// Id of the library this memory belongs to. Allows clients to show a /// per-item source badge in union mode. pub library_id: i32, /// New-contract triple (ADR-0001). `date_utc` is `None` until the /// `backfill_utc_dates` drain has converted the row; clients should /// prefer it over `created` when present. pub date_utc: Option, pub camera_offset_minutes: Option, pub date_taken_tz_source: Option, /// Capture wall clock to display, naive ISO-8601 (`row_display_wall`): /// the camera's clock, which can differ from the owner's day that /// `created` groups under. Render verbatim (ADR-0001). pub wall_clock: Option, } #[derive(Debug, Serialize)] pub struct MemoriesResponse { pub items: Vec, } /// Epoch windows for the memories SQL pre-filter (ADR-0001). /// /// The span feature matches the same calendar position (month-day / /// week-of-year / month) across every past year, so the pre-filter must /// cover the FULL years-back window — not just the current span window — /// or past-year rows are dropped before [`day_in_span`] gets to match /// them. Converted rows are tested on `date_utc` (the true instant); /// unconverted rows on legacy `date_taken`, which sits up to ~15 h off /// the instant under the per-source conventions (wall↔instant confusion /// ≤ 12 h, zone range ≤ 2 h, DST margin 1 h) — hence the ±15 h band. /// By construction the band subsumes the converted window, so the DAO's /// single outer bound on `date_taken` never drops a converted row. pub fn memories_span_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) { let years_lo = now_ts - years_back * 365 * 86_400; (years_lo, now_ts, years_lo - 15 * 3600, now_ts + 15 * 3600) } /// True if `display_day` falls in the span (day/week/month) containing /// `today` — both already in the owner's display zone. Mirrors the legacy /// SQLite `strftime('%m-%d' | '%W' | '%m')` equality: weeks are /// Monday-based and the days before the year's first Monday are week 00. pub fn day_in_span(display_day: NaiveDate, today: NaiveDate, span: MemoriesSpan) -> bool { match span { MemoriesSpan::Day => { display_day.month() == today.month() && display_day.day() == today.day() } MemoriesSpan::Week => sqlite_w_week(display_day) == sqlite_w_week(today), MemoriesSpan::Month => display_day.month() == today.month(), } } /// SQLite's `%W`: Monday-based week-of-year, `0` for the days before the /// year's first Monday, `1` starting on the first Monday. fn sqlite_w_week(d: NaiveDate) -> i32 { let jan1 = NaiveDate::from_ymd_opt(d.year(), 1, 1).expect("Jan 1 exists"); let days_in_week0 = (7 - jan1.weekday().num_days_from_monday() as i64) % 7; let days = (d - jan1).num_days(); if days < days_in_week0 { 0 } else { ((days - days_in_week0) / 7 + 1) as i32 } } /// A date carried by a filename, with its convention (ticket #116). /// /// The two families of pattern make fundamentally different claims: /// - **`Wall`** — the device's own clock, as displayed (screenshots, /// dash / compact name formats). It is a wall clock and needs the /// offset cascade (offset tag → GPS → server zone) to become a true /// instant, exactly like a photo-EXIF wall clock. /// - **`Utc`** — a unix-timestamp name (`1401638400`, `att_1422489664…`) /// is already a true instant; no offset is involved. /// /// Files whose name matches any pattern are **in the filename date /// class**: the filename is the original date of record and outranks /// EXIF / container dates (ADR-0001). Files whose name matches nothing /// are out of the class and keep the global EXIF-wins waterfall. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum FilenameDate { /// Wall clock the device displayed (screenshot / dash / compact). Wall(NaiveDateTime), /// True instant the unix-timestamp pattern names directly. Utc(DateTime), } impl FilenameDate { /// The exact value the legacy `date_taken` column stores for this /// filename: wall patterns as wall-as-UTC (the pre-migration /// convention), timestamp patterns as their epoch. Consumers that /// only need the legacy value (metadata responses, insight prompt /// fallbacks) use this so the legacy contract stays byte-identical. pub fn legacy_datetime(self) -> DateTime { match self { FilenameDate::Wall(wall) => wall.and_utc(), FilenameDate::Utc(dt) => dt, } } /// The exact legacy `date_taken` unix seconds (`legacy_datetime` /// as an epoch). The one-liner most consumers need. pub fn legacy_timestamp(self) -> i64 { self.legacy_datetime().timestamp() } } /// A filename that matched one of the filename-class patterns (#116): /// which pattern fired and the date it carries. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct FilenameMatch { /// Stable pattern name for audit / debug surfaces: `screenshot`, /// `screenshot_compact`, `dash`, `dash_compact`, `compact`, /// `unix_timestamp`. pub pattern: &'static str, pub date: FilenameDate, } /// Match a filename against the filename-class patterns, reporting which /// pattern fired. This is the canonical matcher; `extract_date_from_filename` /// is the date-only projection of it. pub fn match_filename_date(filename: &str) -> Option { // The wall-clock patterns (screenshot / dash / compact) carry the // device's own clock; the unix-timestamp pattern carries a true // instant. `FilenameDate::legacy_datetime()` reproduces the // pre-migration interpretation of each (naive-as-UTC / epoch) for the // legacy column and its consumers. let build_wall_from_ymd_capture = |captures: ®ex::Captures| -> Option { let year = captures.get(1)?.as_str().parse::().ok()?; let month = captures.get(2)?.as_str().parse::().ok()?; let day = captures.get(3)?.as_str().parse::().ok()?; let hour = captures.get(4)?.as_str().parse::().ok()?; let min = captures.get(5)?.as_str().parse::().ok()?; let sec = captures.get(6)?.as_str().parse::().ok()?; NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec) }; // 1. Screenshot format: Screenshot_2014-06-01-20-44-50.png if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})") .ok()? .captures(filename) .and_then(|c| build_wall_from_ymd_capture(&c)) { return Some(FilenameMatch { pattern: "screenshot", date: FilenameDate::Wall(naive), }); } // Screenshot format: Screenshot_20140601[_-]204450.png if let Some(naive) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})") .ok()? .captures(filename) .and_then(|c| build_wall_from_ymd_capture(&c)) { return Some(FilenameMatch { pattern: "screenshot_compact", date: FilenameDate::Wall(naive), }); } // 2. Dash format: 2015-01-09_02-15-15.jpg if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})") .ok()? .captures(filename) .and_then(|c| build_wall_from_ymd_capture(&c)) { return Some(FilenameMatch { pattern: "dash", date: FilenameDate::Wall(naive), }); } // Dash with compact time format: 2015-01-09-021515.jpg if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})") .ok()? .captures(filename) .and_then(|c| build_wall_from_ymd_capture(&c)) { return Some(FilenameMatch { pattern: "dash_compact", date: FilenameDate::Wall(naive), }); } // 3. Compact format: 20140927101712.jpg if let Some(naive) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})") .ok()? .captures(filename) .and_then(|c| build_wall_from_ymd_capture(&c)) { return Some(FilenameMatch { pattern: "compact", date: FilenameDate::Wall(naive), }); } // 4. Timestamp format: 1401638400.jpeg, att_1422489664680106.jpeg, att_142248967186928.jpeg // Matches timestamps with 10-16 digits (seconds, milliseconds, microseconds) if let Some(captures) = regex::Regex::new(r"(\d{10,16})\.").ok()?.captures(filename) { let timestamp_str = captures.get(1)?.as_str(); let len = timestamp_str.len(); // Snapchat used real unix-second filenames in its early era // (e.g. `Snapchat-1383929602.jpg` = 2013-11-08), then switched to // monotonic sequential IDs whose digits overlap plausible epoch // ranges (`Snapchat-1021849065.mp4` truncates to 2002, actually // saved 2021; `Snapchat-1751031586660373917.jpg` is 19 digits, // truncates to 2002, actually 2016). Discriminate by: // - exactly 10 captured digits AND post-2011-09-23 (launch) → real epoch // - anything else under this prefix → sequential ID, fall through // The Snapchat-launch floor catches the 10-digit-2002 case; the // length=10 gate catches the multi-digit sequential IDs (which // get truncated to 16 by the regex above). let lower = filename.to_ascii_lowercase(); let is_snapchat = lower.starts_with("snapchat-"); if is_snapchat && len != 10 { return None; } // Skip autogenerated filenames that start with "10000" (e.g., 1000004178.jpg) // These are not timestamps but auto-generated file IDs if timestamp_str.starts_with("10000") { return None; } // A leading zero rules out a real unix timestamp at any sane // resolution (seconds since 2001-09-09, ms since 1970-01-01 are // both 10+ digits with no leading zero). Filenames like // `000227580005.jpg` are sequential scan IDs, not timestamps. if timestamp_str.starts_with('0') { return None; } // Try milliseconds first (13 digits exactly) if len == 13 && let Some(date_time) = timestamp_str .parse::() .ok() .and_then(DateTime::::from_timestamp_millis) .and_then(plausible_filename_date) { return Some(FilenameMatch { pattern: "unix_timestamp", date: FilenameDate::Utc(date_time), }); } // For 14-16 digits, treat first 10 digits as seconds to avoid far future dates // Examples: att_1422489664680106 (16 digits), att_142248967186928 (15 digits) if (14..=16).contains(&len) && let Some(date_time) = timestamp_str[0..10] .parse::() .ok() .and_then(|ts| DateTime::::from_timestamp(ts, 0)) .and_then(plausible_filename_date) { return Some(FilenameMatch { pattern: "unix_timestamp", date: FilenameDate::Utc(date_time), }); } // Exactly 10 digits - seconds since epoch if len == 10 && let Some(date_time) = timestamp_str .parse::() .ok() .and_then(|ts| DateTime::::from_timestamp(ts, 0)) .and_then(plausible_filename_date) { // Snapchat launched 2011-09-23. A 10-digit Snapchat filename // dated before that is a sequential ID (e.g. // `Snapchat-1021849065.mp4` parses to 2002), not a real epoch. const SNAPCHAT_LAUNCH_TS: i64 = 1_316_736_000; if is_snapchat && date_time.timestamp() < SNAPCHAT_LAUNCH_TS { return None; } return Some(FilenameMatch { pattern: "unix_timestamp", date: FilenameDate::Utc(date_time), }); } // 11-12 digits: try as milliseconds (might be partial millisecond timestamp) if (len == 11 || len == 12) && let Some(date_time) = timestamp_str .parse::() .ok() .and_then(DateTime::::from_timestamp_millis) .and_then(plausible_filename_date) { return Some(FilenameMatch { pattern: "unix_timestamp", date: FilenameDate::Utc(date_time), }); } } None } /// Date-only projection of [`match_filename_date`]: the class match /// without the pattern name. Resolver and consumer call sites use this; /// the audit utility uses the full match. pub fn extract_date_from_filename(filename: &str) -> Option { match_filename_date(filename).map(|m| m.date) } /// Sanity gate for filename-derived timestamps. Real photo capture dates /// live in a narrow window; values outside it are almost always sequential /// scan IDs (`000227580005.jpg` → 1970) or arbitrary numeric suffixes /// (`IMG_21323906751390.jpeg` → 2037) that the regex caught by accident. /// Rejecting them lets the date_resolver waterfall fall through to /// `fs_time`, which is a much better proxy for content age than a fake /// epoch date. fn plausible_filename_date(dt: DateTime) -> Option> { use chrono::Datelike; let year = dt.year(); // 1995 predates digital photography for most users; allowing one year // past `now` covers clock-skew on freshly-taken shots without letting // 2037 timestamps through. let max_year = Utc::now().year() + 1; if (1995..=max_year).contains(&year) { Some(dt) } else { None } } /// Default lookback for `/memories`. The original 15-year cap pre-dated /// most of the imported libraries; bumped to 20 so users with deeper /// archives see those photos surface on the matching anniversary too. pub const DEFAULT_YEARS_BACK: i32 = 20; #[get("/memories")] #[tracing::instrument(name = "list_memories", skip_all)] pub async fn list_memories( _claims: Claims, q: web::Query, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let span = tracing::Span::current(); let span_mode = q.span.unwrap_or(MemoriesSpan::Day); // Grouping happens in the owner's display zone (prefs → app default), // not the client's device offset (ADR-0001): a photo taken in Japan at // 21:00 JST belongs to the owner's day, and a container's UTC-rendered // creation time is a true instant. The legacy `timezone_offset_minutes` // param is still accepted on the wire for old clients but no longer // drives grouping. let display_zone_name = { let prefs = app_state .user_ai_prefs_dao .lock() .ok() .and_then(|mut dao| dao.get_prefs().ok().flatten()); crate::camera_tz::display_zone_for(prefs.as_ref().and_then(|p| p.display_tz.as_deref())) }; let display_zone = chrono_tz::Tz::from_str(&display_zone_name).unwrap_or(chrono_tz::Tz::America__New_York); let items = match gather_memory_items( &app_state, &exif_dao, span_mode, display_zone, q.library.as_deref(), ) { Ok(items) => items, Err(msg) => { warn!("Rejecting /memories request: {}", msg); return HttpResponse::BadRequest().body(msg); } }; span.add_event( "memories_scanned", vec![ KeyValue::new("span", format!("{:?}", span_mode)), KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()), KeyValue::new("result_count", items.len().to_string()), KeyValue::new("display_tz", display_zone_name), KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)), ], ); span.set_status(Status::Ok); HttpResponse::Ok().json(MemoriesResponse { items }) } /// Resolve an "on this day/week/month across past years" window into an /// ordered list of [`MemoryItem`]s. Shared by the `/memories` handler and the /// memory-reel selector so both honour the same library resolution, per-library /// exclusions, and sort order. Returns `Err(message)` only when the /// `library` param is invalid (callers map that to 400); per-library /// query/lock failures are logged and skipped, matching the handler's /// best-effort behaviour. /// /// Grouping is done in the owner's display zone (ADR-0001), on the row's /// *true* instant — so a container's UTC-rendered creation time and an /// unconverted EXIF wall clock both land in the same day a photo taken /// locally did. The SQL pass is an interval pre-filter; exact span-day /// matching happens here in Rust via [`date_view`]. pub fn gather_memory_items( app_state: &AppState, exif_dao: &Mutex>, span_mode: MemoriesSpan, display_zone: chrono_tz::Tz, library_param: Option<&str>, ) -> Result, String> { let years_back: i64 = DEFAULT_YEARS_BACK as i64; let now = Utc::now(); let now_ts = now.timestamp(); let today_d = display_zone .from_utc_datetime(&now.naive_utc()) .date_naive(); // The SQL pass is a coarse pre-filter over the FULL years-back window // (not the current span window) because this feature matches the same // month-day / week / month across every past year; [`day_in_span`] does // the exact matching in Rust (ADR-0001). let (conv_lo, conv_hi, unconv_lo, unconv_hi) = memories_span_windows(now_ts, years_back); let server_zone = crate::camera_tz::server_zone(); debug!( "gather_memory_items: span={:?} display_zone={:?} years_back={}", span_mode, display_zone, years_back ); let library = crate::libraries::resolve_library_param_state(app_state, library_param)?; let libraries_to_scan: Vec<&crate::libraries::Library> = match library { Some(lib) => vec![lib], None => app_state.libraries.iter().collect(), }; // (item, date) tuples — `date` is the canonical NaiveDate of the // memory in the client's tz, used as the primary sort key. let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new(); for lib in &libraries_to_scan { let base = Path::new(&lib.root_path); let effective = lib.effective_excluded_dirs(&app_state.excluded_dirs); let path_excluder = PathExcluder::new(base, &effective); let rows = match exif_dao.lock() { Ok(mut dao) => { match dao.get_memories_in_window(lib.id, conv_lo, conv_hi, unconv_lo, unconv_hi) { Ok(rows) => rows, Err(e) => { warn!( "Failed to query memories for library '{}': {:?}", lib.name, e ); continue; } } } Err(e) => { warn!("Failed to lock EXIF DAO: {:?}", e); continue; } }; for row in rows { // Apply per-library exclusions in Rust — they're a small // set and pushing them into the SQL WHERE adds bind-param // gymnastics with no measurable win at this scale. let full_path = base.join(&row.file_path); if path_excluder.is_excluded(&full_path) { trace!("Memory excluded by PathExcluder: {:?}", full_path); continue; } let Some(display_day) = crate::date_view::row_display_day(&row, display_zone, server_zone) else { continue; }; if !day_in_span(display_day, today_d, span_mode) { continue; } let created = crate::date_view::row_true_instant(&row, display_zone, server_zone); let wall_clock = crate::date_view::row_wall_clock(&row, display_zone, server_zone); memories_with_dates.push(( MemoryItem { path: row.file_path, created, modified: Some(row.last_modified), library_id: lib.id, date_utc: row.date_utc, camera_offset_minutes: row.camera_offset_minutes, date_taken_tz_source: row.date_taken_tz_source, wall_clock, }, display_day, )); } } // Sort once over the merged result set. The SQL filter handles the // matching; sort order is purely UI concern. sort_memories(&mut memories_with_dates, span_mode); Ok(memories_with_dates.into_iter().map(|(m, _)| m).collect()) } /// Order `(item, display_day)` pairs for the given span, oldest first. /// /// Rows arrive in DB order, merged across libraries, so every span needs a /// timestamp key within a day — without one, a day's photos keep that /// arbitrary order and their times jump back and forth. fn sort_memories(memories: &mut [(MemoryItem, NaiveDate)], span: MemoriesSpan) { // Timestamped items before ones without a date. let by_created = |a: &MemoryItem, b: &MemoryItem| match (a.created, b.created) { (Some(at), Some(bt)) => at.cmp(&bt), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => std::cmp::Ordering::Equal, }; match span { // Month / Week: full date then timestamp (oldest → newest). MemoriesSpan::Month | MemoriesSpan::Week => { memories.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| by_created(&a.0, &b.0))); } // Day: same calendar day across years, sorted by timestamp. MemoriesSpan::Day => memories.sort_by(|a, b| by_created(&a.0, &b.0)), } } #[cfg(test)] mod tests { use super::*; use chrono::{Datelike, Timelike}; use std::fs::{self, File}; use tempfile::tempdir; #[test] fn test_extract_date_from_filename_screenshot_format() { let filename = "Screenshot_2014-06-01-20-44-50.png"; let date_time = match extract_date_from_filename(filename).unwrap() { FilenameDate::Wall(naive) => naive, other => panic!("expected a wall-clock pattern, got {:?}", other), }; assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 6); assert_eq!(date_time.day(), 1); assert_eq!(date_time.hour(), 20); assert_eq!(date_time.minute(), 44); assert_eq!(date_time.second(), 50); } #[test] fn test_extract_date_from_filename_screenshot_less_dashes_format() { let filename = "Screenshot_20140601-204450.png"; let date_time = match extract_date_from_filename(filename).unwrap() { FilenameDate::Wall(naive) => naive, other => panic!("expected a wall-clock pattern, got {:?}", other), }; assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 6); assert_eq!(date_time.day(), 1); assert_eq!(date_time.hour(), 20); assert_eq!(date_time.minute(), 44); assert_eq!(date_time.second(), 50); } #[test] fn test_extract_date_from_filename_screenshot_underscores_format() { let filename = "20140601_204450.png"; let date_time = match extract_date_from_filename(filename).unwrap() { FilenameDate::Wall(naive) => naive, other => panic!("expected a wall-clock pattern, got {:?}", other), }; assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 6); assert_eq!(date_time.day(), 1); assert_eq!(date_time.hour(), 20); assert_eq!(date_time.minute(), 44); assert_eq!(date_time.second(), 50); } #[test] fn test_extract_date_from_filename_dash_format() { let filename = "2015-01-09_02-15-15.jpg"; let date_time = match extract_date_from_filename(filename).unwrap() { FilenameDate::Wall(naive) => naive, other => panic!("expected a wall-clock pattern, got {:?}", other), }; assert_eq!(date_time.year(), 2015); assert_eq!(date_time.month(), 1); assert_eq!(date_time.day(), 9); assert_eq!(date_time.hour(), 2); assert_eq!(date_time.minute(), 15); assert_eq!(date_time.second(), 15); } #[test] fn test_extract_date_from_filename_dash_compact_time_format() { let filename = "2015-01-09-021515.jpg"; let date_time = match extract_date_from_filename(filename).unwrap() { FilenameDate::Wall(naive) => naive, other => panic!("expected a wall-clock pattern, got {:?}", other), }; assert_eq!(date_time.year(), 2015); assert_eq!(date_time.month(), 1); assert_eq!(date_time.day(), 9); assert_eq!(date_time.hour(), 2); assert_eq!(date_time.minute(), 15); assert_eq!(date_time.second(), 15); } #[test] fn test_extract_date_from_filename_compact_format() { let filename = "20140927101712.jpg"; let date_time = match extract_date_from_filename(filename).unwrap() { FilenameDate::Wall(naive) => naive, other => panic!("expected a wall-clock pattern, got {:?}", other), }; assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 9); assert_eq!(date_time.day(), 27); assert_eq!(date_time.hour(), 10); assert_eq!(date_time.minute(), 17); assert_eq!(date_time.second(), 12); } #[test] fn test_extract_date_from_filename_timestamp_format() { let filename = "xyz_1401638400.jpeg"; // Unix timestamp for 2014-06-01 16:00:00 UTC let date_time = match extract_date_from_filename(filename).unwrap() { FilenameDate::Utc(dt) => dt, other => panic!("expected a unix-timestamp pattern, got {:?}", other), }; assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 6); assert_eq!(date_time.day(), 1); assert_eq!(date_time.hour(), 16); assert_eq!(date_time.minute(), 0); assert_eq!(date_time.second(), 0); } #[test] fn test_extract_date_from_filename_timestamp_millis_format() { let filename = "xyz_1401638400000.jpeg"; // Unix timestamp in milliseconds let date_time = match extract_date_from_filename(filename).unwrap() { FilenameDate::Utc(dt) => dt, other => panic!("expected a unix-timestamp pattern, got {:?}", other), }; assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 6); assert_eq!(date_time.day(), 1); assert_eq!(date_time.hour(), 16); assert_eq!(date_time.minute(), 0); assert_eq!(date_time.second(), 0); } #[test] fn test_extract_date_from_filename_attachment_15_digits() { // att_142248967186928.jpeg - 15 digits, should parse first 10 as seconds // 1422489671 = 2015-01-28 23:07:51 UTC (converts to local timezone) let filename = "att_142248967186928.jpeg"; let date_time = match extract_date_from_filename(filename).unwrap() { FilenameDate::Utc(dt) => dt, other => panic!("expected a unix-timestamp pattern, got {:?}", other), }; // Verify year and month are correct (2015-01) assert_eq!(date_time.year(), 2015); assert_eq!(date_time.month(), 1); // Day may be 28 or 29 depending on timezone assert!(date_time.day() >= 28 && date_time.day() <= 29); // Verify timestamp is within expected range (should be around 1422489671) let timestamp = date_time.timestamp(); assert!((1422480000..=1422576000).contains(×tamp)); // Jan 28-29, 2015 } #[test] fn test_extract_date_from_filename_attachment_16_digits() { // att_1422489664680106.jpeg - 16 digits, should parse first 10 as seconds // 1422489664 = 2015-01-28 23:07:44 UTC (converts to local timezone) let filename = "att_1422489664680106.jpeg"; let date_time = match extract_date_from_filename(filename).unwrap() { FilenameDate::Utc(dt) => dt, other => panic!("expected a unix-timestamp pattern, got {:?}", other), }; // Verify year and month are correct (2015-01) assert_eq!(date_time.year(), 2015); assert_eq!(date_time.month(), 1); // Day may be 28 or 29 depending on timezone assert!(date_time.day() >= 28 && date_time.day() <= 29); // Verify timestamp is within expected range (should be around 1422489664) let timestamp = date_time.timestamp(); assert!((1422480000..=1422576000).contains(×tamp)); // Jan 28-29, 2015 } #[test] fn test_extract_date_from_filename_autogenerated_should_not_match() { // Autogenerated filenames like 1000004178.jpg should NOT be parsed as timestamps // These start with "10000" which would be Sept 2001 if parsed literally let filename = "1000004178.jpg"; let date_time = extract_date_from_filename(filename); assert!( date_time.is_none(), "Autogenerated filenames starting with 10000 should not be parsed as dates" ); } #[test] fn test_extract_date_from_filename_leading_zero_scan_id_should_not_match() { // Sequential film-scan IDs like 000227580005.jpg parsed as a 12-digit // ms timestamp resolve to 1970-01-03; the leading zero rules out a // real epoch value at any sane resolution. Resolver should fall // through to fs_time instead of pinning the photo to 1970. assert!(extract_date_from_filename("000227580005.jpg").is_none()); } #[test] fn test_extract_date_from_filename_far_future_should_not_match() { // IMG_21323906751390.jpeg → first 10 digits = 2132390675 → 2037. // Plausibility gate rejects it so the resolver falls through to // fs_time (which carries the real ingest date). assert!(extract_date_from_filename("IMG_21323906751390.jpeg").is_none()); } #[test] fn test_extract_date_from_filename_snapchat_sequential_ids_rejected() { // Modern Snapchat-prefixed filenames carry sequential app-assigned // IDs whose digits happen to fall inside plausible epoch ranges // when truncated. Reported cases (real save dates per FileModifyDate): // Snapchat-1021849065.mp4 → 10 digits → 2002-05-19 (saved 2021) // Snapchat-1751031586660373917.jpg → 19 digits → 2002-09-09 (saved 2016) // We discriminate by length + Snapchat-launch floor: only exactly // 10 digits AND post-2011-09-23 (Snapchat launch) is treated as // a real unix epoch. Anything else falls through to fs_time. assert!(extract_date_from_filename("Snapchat-1021849065.mp4").is_none()); assert!(extract_date_from_filename("Snapchat-1751031586660373917.jpg").is_none()); // Case-insensitive match — lowercase variant should also reject. assert!(extract_date_from_filename("snapchat-1021849065.mp4").is_none()); } #[test] fn test_extract_date_from_filename_snapchat_early_era_unix_epoch() { // Early Snapchat (2013-2014ish) wrote real unix-second filenames. // Snapchat-1383929602.jpg → 1383929602 = 2013-11-08 16:53:22 UTC. // The blanket-prefix denial introduced for sequential IDs broke // these — restore via a length=10 + post-launch sanity gate. let date_time = match extract_date_from_filename("Snapchat-1383929602.jpg").unwrap() { FilenameDate::Utc(dt) => dt, other => panic!("expected a unix-timestamp pattern, got {:?}", other), }; assert_eq!(date_time.timestamp(), 1383929602); } // Pattern-name seam (ticket #117): the audit utility prints which // pattern fired, so every class pattern must report a stable name. #[test] fn test_match_filename_date_reports_pattern_names() { let cases: &[(&str, &str)] = &[ ("Screenshot_2014-06-01-20-44-50.png", "screenshot"), ("Screenshot_20140601_204450.png", "screenshot_compact"), ("2015-01-09_02-15-15.jpg", "dash"), ("2015-01-09-021515.jpg", "dash_compact"), ("20140927101712.jpg", "compact"), ("att_1422489664680106.jpeg", "unix_timestamp"), ("1401638400.jpeg", "unix_timestamp"), ]; for (name, pattern) in cases { let match_ = match_filename_date(name).unwrap_or_else(|| panic!("no match for {}", name)); assert_eq!(match_.pattern, *pattern, "pattern name for {}", name); } } #[test] fn test_match_filename_date_out_of_class_is_none() { assert_eq!(match_filename_date("IMG_12345.jpg"), None); assert_eq!(match_filename_date("000227580005.jpg"), None); } // The obsolete `test_memory_date_priority_*` tests covered the old // request-time waterfall in `get_memory_date_with_priority`. Their // replacement lives in `crate::date_resolver::tests` (resolver // waterfall) and the SQL surface is exercised by integration tests // that hit `get_memories_in_window` directly. #[test] fn test_path_excluder_absolute_under_base() { let tmp = tempdir().unwrap(); let base = tmp.path(); // Simulate structure: // base/photos/private/secret.jpg // base/photos/public/ok.jpg // base/screenshots/img.png let photos_private = base.join("photos/private"); let photos_public = base.join("photos/public"); let screenshots = base.join("screenshots"); fs::create_dir_all(&photos_private).unwrap(); fs::create_dir_all(&photos_public).unwrap(); fs::create_dir_all(&screenshots).unwrap(); let secret = photos_private.join("secret.jpg"); let ok = photos_public.join("ok.jpg"); let shot = screenshots.join("img.png"); File::create(&secret).unwrap(); File::create(&ok).unwrap(); File::create(&shot).unwrap(); // Exclude "/photos/private" and "/screenshots" under base let excluded = vec![ String::from("/photos/private"), String::from("/screenshots"), ]; let excluder = PathExcluder::new(base, &excluded); assert!(excluder.is_excluded(&secret), "secret should be excluded"); assert!( excluder.is_excluded(&shot), "screenshots should be excluded" ); assert!( !excluder.is_excluded(&ok), "public photo should NOT be excluded" ); } #[test] fn test_path_excluder_pattern_anywhere_under_base() { let tmp = tempdir().unwrap(); let base = tmp.path(); // Simulate: // base/a/tmp_file.jpg // base/b/normal.jpg // base/c/sometmpdir/file.jpg let a = base.join("a"); let b = base.join("b"); let c = base.join("c/tmp"); fs::create_dir_all(&a).unwrap(); fs::create_dir_all(&b).unwrap(); fs::create_dir_all(&c).unwrap(); let tmp_file = a.join("tmp_file.jpg"); let normal = b.join("normal.jpg"); let tmp_dir_file = c.join("file.jpg"); File::create(&tmp_file).unwrap(); File::create(&normal).unwrap(); File::create(&tmp_dir_file).unwrap(); // Exclude any path containing "tmp" let excluded = vec![String::from("tmp")]; let excluder = PathExcluder::new(base, &excluded); assert!( !excluder.is_excluded(&tmp_file), "file with 'tmp' in name should NOT be excluded" ); assert!( excluder.is_excluded(&tmp_dir_file), "file in directory with 'tmp' in path should be excluded" ); assert!( !excluder.is_excluded(&normal), "file without 'tmp' in its path should NOT be excluded" ); } #[test] fn test_path_excluder_mixed_absolute_and_pattern() { let tmp = tempdir().unwrap(); let base = tmp.path(); // Simulate: // base/photos/private/secret_tmp.jpg -> excluded by absolute dir rule // base/photos/private/secret.jpg -> excluded by absolute dir rule // base/photos/tmp/public.jpg -> excluded by pattern "tmp" (dir name) // base/photos/public/tmp_public.jpg -> NOT excluded (file name contains "tmp" but not equal) // base/other/keep.jpg -> NOT excluded let photos_private = base.join("photos/private"); let photos_tmp = base.join("photos/tmp"); let photos_public = base.join("photos/public"); let other = base.join("other"); fs::create_dir_all(&photos_private).unwrap(); fs::create_dir_all(&photos_tmp).unwrap(); fs::create_dir_all(&photos_public).unwrap(); fs::create_dir_all(&other).unwrap(); let secret_tmp = photos_private.join("secret_tmp.jpg"); let secret = photos_private.join("secret.jpg"); let tmp_dir_file = photos_tmp.join("public.jpg"); let tmp_in_name = photos_public.join("tmp_public.jpg"); let keep = other.join("keep.jpg"); File::create(&secret_tmp).unwrap(); File::create(&secret).unwrap(); File::create(&tmp_dir_file).unwrap(); File::create(&tmp_in_name).unwrap(); File::create(&keep).unwrap(); // Mixed: exclude "/photos/private" (dir) and any component equal to "tmp" let excluded = vec![String::from("/photos/private"), String::from("tmp")]; let excluder = PathExcluder::new(base, &excluded); // Entire private tree is excluded by dir rule assert!(excluder.is_excluded(&secret_tmp)); assert!(excluder.is_excluded(&secret)); // Dir 'tmp' under photos excluded by pattern assert!(excluder.is_excluded(&tmp_dir_file)); // File name containing 'tmp' but not equal should NOT be excluded assert!(!excluder.is_excluded(&tmp_in_name)); // keep.jpg doesn't match any rule assert!(!excluder.is_excluded(&keep)); } #[test] fn test_week_span_sorting_chronological_by_day() { // Test that Week span sorts by: // 1. Day of month (ascending) // 2. Full timestamp oldest to newest (year + time combined) // Create test data: // - Jan 15, 2024 at 9:00 AM // - Jan 15, 2020 at 10:00 AM // - Jan 16, 2021 at 8:00 AM let jan_15_2024_9am = NaiveDate::from_ymd_opt(2024, 1, 15) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() .and_utc() .timestamp(); let jan_15_2020_10am = NaiveDate::from_ymd_opt(2020, 1, 15) .unwrap() .and_hms_opt(10, 0, 0) .unwrap() .and_utc() .timestamp(); let jan_16_2021_8am = NaiveDate::from_ymd_opt(2021, 1, 16) .unwrap() .and_hms_opt(8, 0, 0) .unwrap() .and_utc() .timestamp(); let mut memories_with_dates = [ ( MemoryItem { path: "photo1.jpg".to_string(), created: Some(jan_15_2024_9am), modified: Some(jan_15_2024_9am), library_id: 1, date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, }, NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), ), ( MemoryItem { path: "photo2.jpg".to_string(), created: Some(jan_15_2020_10am), modified: Some(jan_15_2020_10am), library_id: 1, date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, }, NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(), ), ( MemoryItem { path: "photo3.jpg".to_string(), created: Some(jan_16_2021_8am), modified: Some(jan_16_2021_8am), library_id: 1, date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, }, NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(), ), ]; // Sort using Week span logic memories_with_dates.sort_by(|a, b| { // First, sort by day of month let day_cmp = a.1.day().cmp(&b.1.day()); if day_cmp != std::cmp::Ordering::Equal { return day_cmp; } // Then sort by full created timestamp (oldest to newest) match (a.0.created, b.0.created) { (Some(a_time), Some(b_time)) => a_time.cmp(&b_time), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => std::cmp::Ordering::Equal, } }); // Expected order: // 1. Jan 15, 2020 at 10:00 AM (oldest Jan 15 photo) // 2. Jan 15, 2024 at 9:00 AM (newer Jan 15 photo) // 3. Jan 16, 2021 at 8:00 AM (all Jan 16 photos after Jan 15) assert_eq!(memories_with_dates[0].0.created.unwrap(), jan_15_2020_10am); assert_eq!(memories_with_dates[1].0.created.unwrap(), jan_15_2024_9am); assert_eq!(memories_with_dates[2].0.created.unwrap(), jan_16_2021_8am); } #[test] fn month_span_sorts_by_time_within_a_day() { // Same display day, rows in DB order (merged across libraries) — // the Month sort used to keep this order, so times jumped around. let day = NaiveDate::from_ymd_opt(2019, 9, 14).unwrap(); let at = |h: u32| day.and_hms_opt(h, 0, 0).unwrap().and_utc().timestamp(); let item = |path: &str, created: Option, library_id: i32| MemoryItem { path: path.to_string(), created, modified: created, library_id, date_utc: created, camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, }; let earlier_day = NaiveDate::from_ymd_opt(2019, 9, 2).unwrap(); let mut memories = vec![ (item("lib1-15h.jpg", Some(at(15)), 1), day), (item("undated.jpg", None, 1), day), (item("lib2-09h.jpg", Some(at(9)), 2), day), (item("lib1-12h.jpg", Some(at(12)), 1), day), ( item("sept-2nd.jpg", Some(at(20) - 12 * 86_400), 2), earlier_day, ), ]; sort_memories(&mut memories, MemoriesSpan::Month); let paths: Vec<&str> = memories.iter().map(|(m, _)| m.path.as_str()).collect(); assert_eq!( paths, vec![ "sept-2nd.jpg", "lib2-09h.jpg", "lib1-12h.jpg", "lib1-15h.jpg", "undated.jpg" ] ); } // ---- Day-matching (ticket #119) ---- #[test] fn span_windows_cover_the_full_years_back_range() { // Regression guard for the "current span window only" bug: the // day/week/month spans match across PAST YEARS, so the SQL // pre-filter must span the whole years-back horizon. If this // narrowed to the current day/week/month, /memories would return // only photos taken right now. let now = 1_700_000_000i64; let (clo, chi, ulo, uhi) = memories_span_windows(now, 20); let lo_bound = now - 20 * 365 * 86_400; assert_eq!(clo, lo_bound, "converted window must start at the horizon"); assert_eq!(chi, now, "converted window must end at now"); assert_eq!( ulo, lo_bound - 15 * 3600, "unconverted band must pad the horizon" ); assert_eq!(uhi, now + 15 * 3600, "unconverted band must pad now"); // The band subsumes the converted window so the DAO's single // outer bound on legacy date_taken never drops a converted row. assert!(ulo <= clo && chi <= uhi); } #[test] fn day_in_span_day_and_month() { let today = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(); assert!(day_in_span( NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(), today, MemoriesSpan::Day )); assert!(!day_in_span( NaiveDate::from_ymd_opt(2024, 7, 5).unwrap(), today, MemoriesSpan::Day )); assert!(day_in_span( NaiveDate::from_ymd_opt(2020, 7, 4).unwrap(), today, MemoriesSpan::Day )); assert!(day_in_span( NaiveDate::from_ymd_opt(2024, 7, 30).unwrap(), today, MemoriesSpan::Month )); assert!(!day_in_span( NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(), today, MemoriesSpan::Month )); } #[test] fn day_in_span_week_is_monday_based_with_week_zero() { let monday_2024 = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); // Mon // Same week: Sunday Jul 7 is still week of Mon Jul 1. assert!(day_in_span( NaiveDate::from_ymd_opt(2024, 7, 7).unwrap(), monday_2024, MemoriesSpan::Week )); // Next Monday is the next week. assert!(!day_in_span( NaiveDate::from_ymd_opt(2024, 7, 8).unwrap(), monday_2024, MemoriesSpan::Week )); // Previous Sunday is the previous week. assert!(!day_in_span( NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(), monday_2024, MemoriesSpan::Week )); // 2022 week 00: Jan 1 (Sat) and Jan 2 (Sun) share week 00; Jan 3 // (Mon) starts week 1. let jan1_2022 = NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(); assert!(day_in_span( NaiveDate::from_ymd_opt(2022, 1, 2).unwrap(), jan1_2022, MemoriesSpan::Week )); assert!(!day_in_span( NaiveDate::from_ymd_opt(2022, 1, 3).unwrap(), jan1_2022, MemoriesSpan::Week )); } #[test] fn sqlite_w_week_matches_known_values() { // 2024: Jan 1 is a Monday → week 1 starts Jan 1. assert_eq!( sqlite_w_week(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()), 1 ); assert_eq!( sqlite_w_week(NaiveDate::from_ymd_opt(2024, 12, 30).unwrap()), 53 ); // 2022: Jan 1 is a Saturday → two-day week 00. assert_eq!( sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 1).unwrap()), 0 ); assert_eq!( sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 2).unwrap()), 0 ); assert_eq!( sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 3).unwrap()), 1 ); // 2023: Jan 1 is a Sunday → one-day week 00. assert_eq!( sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()), 0 ); assert_eq!( sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 2).unwrap()), 1 ); assert_eq!( sqlite_w_week(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()), 52 ); } /// AC-2 / AC-3 / AC-7: seam test with mixed fixtures — a converted /// row and two unconverted rows (EXIF wall convention + container UTC /// convention) all group into the owner's display-zone month, and each /// item carries its true instant under the per-source convention. mod api { use super::*; use crate::database::SqliteExifDao; use crate::database::models::InsertImageExif; use crate::database::test::in_memory_db_connection; fn seed_row(dao: &mut SqliteExifDao, rel: &str, date_taken: i64, source: &str) { dao.store_exif(InsertImageExif { library_id: 1, file_path: rel.to_string(), camera_make: None, camera_model: None, lens_model: None, width: None, height: None, orientation: None, gps_latitude: None, gps_longitude: None, gps_altitude: None, focal_length: None, aperture: None, shutter_speed: None, iso: None, date_taken: Some(date_taken), created_time: 0, last_modified: 0, content_hash: None, size_bytes: None, phash_64: None, dhash_64: None, date_taken_source: Some(source.to_string()), date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, }) .expect("insert row"); } #[actix_rt::test] async fn memories_mixed_rows_group_into_owner_month() { let _ = env_logger::builder().is_test(true).try_init(); let state = AppState::test_state(); let zone = chrono_tz::Tz::America__New_York; // Month span over the 5th of the CURRENT month in past years, // at 12:00 naive: under all three conventions (camera wall in // the server zone, container UTC, converted instant) the // grouping day can't roll across midnight, so the fixture is // stable across DST transitions and leap years. let now = Utc::now(); let (month, year) = (now.month(), now.year()); let at_12 = |y: i32| { NaiveDate::from_ymd_opt(y, month, 5) .unwrap() .and_hms_opt(12, 0, 0) .unwrap() }; let mut dao = SqliteExifDao::from_connection(in_memory_db_connection()); // 1. Converted row: true instant 12:00 UTC (no camera offset). let conv_instant = at_12(year - 3).and_utc().timestamp(); seed_row(&mut dao, "conv.jpg", conv_instant, "exif"); dao.backfill_date_taken( 1, "conv.jpg", conv_instant, "exif", Some(conv_instant), None, Some("utc_direct"), ) .expect("convert row"); // 2. Unconverted EXIF row: legacy is the camera wall read as // UTC — the true moment is 12:00 in the server zone. let exif_wall = at_12(year - 1); let exif_legacy = exif_wall.and_utc().timestamp(); seed_row(&mut dao, "unconv-exif.jpg", exif_legacy, "exif"); // 3. Unconverted container row: legacy is the raw UTC creation // time string rendered in the server zone (the old server // read the epoch as a naive string and stamped it through // its local zone). Pre-#115 this row double-shifted in // /memories; it must now land in the same owner month as // the other two. let vid_legacy = at_12(year - 2).and_utc().timestamp(); seed_row(&mut dao, "unconv-vid.mp4", vid_legacy, "exiftool"); let exif_mutex: Mutex> = Mutex::new(Box::new(dao)); let items = gather_memory_items(&state, &exif_mutex, MemoriesSpan::Month, zone, None) .expect("gather"); let by_path = |p: &str| items.iter().find(|it| it.path == p); // All three rows group into the same owner month (AC-2/AC-3). let conv = by_path("conv.jpg").expect("converted row must be returned"); let exif_row = by_path("unconv-exif.jpg").expect("unconverted EXIF row must be returned"); let vid = by_path("unconv-vid.mp4").expect("unconverted container row must be returned"); // AC-1: the triple rides along — populated for the converted // row, None for the unconverted ones. assert_eq!(conv.date_utc, Some(conv_instant)); assert_eq!(conv.camera_offset_minutes, None); assert_eq!(conv.date_taken_tz_source.as_deref(), Some("utc_direct")); assert_eq!(exif_row.date_utc, None); assert_eq!(exif_row.camera_offset_minutes, None); assert_eq!(exif_row.date_taken_tz_source, None); assert_eq!(vid.date_utc, None); // AC-2: `created` is the true instant under each row's // per-source convention. The server zone (the zone the old // server rendered legacy values in) is a deploy property, so // the expected values are derived from the convention, not // hardcoded. let server_zone = crate::camera_tz::server_zone(); let legacy_as_wall = |ts: i64| { chrono::DateTime::from_timestamp(ts, 0) .expect("valid epoch") .naive_utc() }; // exif: camera wall reinterpreted in the server zone. let exif_expected = match server_zone { Some(z) => z .from_local_datetime(&legacy_as_wall(exif_legacy)) .single() .expect("camera wall in server zone") .timestamp(), None => exif_legacy, }; // exiftool video: raw UTC naive rendered in the server zone, // read back as UTC. let vid_expected = match server_zone { Some(z) => z .timestamp_opt(vid_legacy, 0) .single() .expect("server-zone render") .naive_local() .and_utc() .timestamp(), None => vid_legacy, }; assert_eq!(conv.created, Some(conv_instant)); assert_eq!(exif_row.created, Some(exif_expected)); assert_eq!(vid.created, Some(vid_expected)); } /// AC-1 (response shape): the serialized `/memories` item carries /// the new-contract triple as explicit fields — null while the row /// is unconverted. #[test] fn memories_response_shape_carries_triple() { let item = MemoryItem { path: "a.jpg".into(), created: Some(1_700_000_000), modified: Some(0), library_id: 1, date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, }; let v: serde_json::Value = serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap(); let it = &v["items"][0]; assert!(it.get("date_utc").is_some() && it["date_utc"].is_null()); assert!( it.get("camera_offset_minutes").is_some() && it["camera_offset_minutes"].is_null() ); assert!(it.get("date_taken_tz_source").is_some()); } } }