Files
ImageApi/src/data/mod.rs
T
Cameron CordesandClaude Opus 5.5 30803dca54 feat(dates): calendar-day date filters in the owner's display zone (#152)
/photos, /photos/exif and unified search take date_from_day /
date_to_day (YYYY-MM-DD): the server turns them into the first day's
start and the last day's end in the owner's display zone, the same days
/memories groups by. Each replaces its instant counterpart. Clients stop
computing midnights in the device's zone, which drifted by the travel
offset whenever the phone's zone differed from display_tz.

e2e check: every fixture is included by its display-zone day and
excluded by the days either side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:10:27 -04:00

774 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{fs, str::FromStr};
use crate::database::models::ImageExif;
use anyhow::{Context, anyhow};
use chrono::{DateTime, Utc};
use log::error;
use actix_web::error::ErrorUnauthorized;
use actix_web::{Error, FromRequest, HttpRequest, dev, http::header};
use futures::future::{Ready, err, ok};
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct Token<'a> {
pub token: &'a str,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Claims {
pub sub: String,
pub exp: i64,
}
#[cfg(test)]
pub mod helper {
use super::Claims;
use chrono::{Duration, Utc};
impl Claims {
pub fn valid_user(user_id: String) -> Self {
Claims {
sub: user_id,
exp: (Utc::now() + Duration::minutes(1)).timestamp(),
}
}
}
}
pub fn secret_key() -> String {
if cfg!(test) {
String::from("test_key")
} else {
dotenv::var("SECRET_KEY").expect("SECRET_KEY env not set!")
}
}
impl FromStr for Claims {
type Err = jsonwebtoken::errors::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let token = s.strip_prefix("Bearer ").ok_or_else(|| {
jsonwebtoken::errors::Error::from(jsonwebtoken::errors::ErrorKind::InvalidToken)
})?;
match decode::<Claims>(
token,
&DecodingKey::from_secret(secret_key().as_bytes()),
&Validation::new(Algorithm::HS256),
) {
Ok(data) => Ok(data.claims),
Err(other) => {
error!("DecodeError: {}", other);
Err(other)
}
}
}
}
impl FromRequest for Claims {
type Error = Error;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut dev::Payload) -> Self::Future {
req.headers()
.get(header::AUTHORIZATION)
.map_or_else(
|| Err(anyhow!("No authorization header")),
|header| {
header
.to_str()
.context("Unable to read Authorization header to string")
},
)
.and_then(|header| {
Claims::from_str(header)
.with_context(|| "Unable to decode token from Authorization header")
})
.map_or_else(
|e| {
error!("{}", e);
err(ErrorUnauthorized("Bad token"))
},
ok,
)
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PhotosResponse {
pub photos: Vec<String>,
pub dirs: Vec<String>,
/// Library id for each entry in `photos`, same length and ordering.
/// Parallel array rather than an object per row to keep the payload
/// small and backwards-compatible with older clients.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub photo_libraries: Vec<i32>,
// Pagination metadata (only present when limit is set)
#[serde(skip_serializing_if = "Option::is_none")]
pub total_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_more: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_offset: Option<i64>,
}
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum SortType {
Shuffle,
NameAsc,
NameDesc,
TagCountAsc,
TagCountDesc,
DateTakenAsc,
DateTakenDesc,
}
#[derive(Deserialize)]
pub struct FilesRequest {
pub path: String,
// comma separated numbers
pub tag_ids: Option<String>,
pub exclude_tag_ids: Option<String>,
pub tag_filter_mode: Option<FilterMode>,
pub recursive: Option<bool>,
pub sort: Option<SortType>,
// EXIF-based search parameters
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub lens_model: Option<String>,
// GPS location search
pub gps_lat: Option<f64>,
pub gps_lon: Option<f64>,
pub gps_radius_km: Option<f64>,
// Date range filtering: inclusive true instants (unix seconds), compared
// against each row's true instant (ADR-0001, #152). For calendar days,
// send `date_from_day` / `date_to_day` (`YYYY-MM-DD`) instead: the server
// takes their bounds in the owner's display zone, and each replaces its
// instant counterpart.
pub date_from: Option<i64>,
pub date_to: Option<i64>,
pub date_from_day: Option<String>,
pub date_to_day: Option<String>,
// Media type filtering
pub media_type: Option<MediaType>,
// Pagination parameters (optional - backward compatible)
pub limit: Option<i64>,
pub offset: Option<i64>,
/// Optional library filter. Accepts a library id (e.g. "1") or name
/// (e.g. "main"). When omitted, results span all libraries.
pub library: Option<String>,
/// When true, include rows soft-marked as duplicates of another file
/// (i.e. `image_exif.duplicate_of_hash IS NOT NULL`). Default false —
/// the standard /photos listing hides demoted siblings so the grid
/// silently shrinks after a resolve. The Apollo duplicates modal
/// passes `true` so it can show both survivors and demoted members
/// inside a group.
#[serde(default)]
pub include_duplicates: Option<bool>,
}
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
pub enum FilterMode {
Any,
All,
}
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum MediaType {
Photo,
Video,
All,
}
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum PhotoSize {
Full,
XLarge,
Large,
Medium,
Thumb,
}
#[derive(Debug, Deserialize)]
pub struct ThumbnailRequest {
pub(crate) path: String,
#[allow(dead_code)] // Part of API contract, may be used in future
pub(crate) size: Option<PhotoSize>,
#[serde(default)]
#[allow(dead_code)] // Part of API contract, may be used in future
pub(crate) format: Option<ThumbnailFormat>,
#[serde(default)]
#[allow(dead_code)] // Part of API contract, may be used in future
pub(crate) shape: Option<ThumbnailShape>,
/// Optional library filter. Accepts a library id (e.g. "1") or name
/// (e.g. "main"). When omitted, defaults to the primary library.
#[serde(default)]
pub(crate) library: Option<String>,
}
#[derive(Debug, Deserialize, PartialEq)]
pub enum ThumbnailFormat {
#[serde(rename = "gif")]
Gif,
#[serde(rename = "image")]
Image,
}
#[derive(Debug, Deserialize, PartialEq)]
pub enum ThumbnailShape {
#[serde(rename = "circle")]
Circle,
#[serde(rename = "square")]
Square,
}
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct CreateAccountRequest {
pub username: String,
pub password: String,
pub confirmation: String,
}
#[derive(Deserialize)]
pub struct AddFavoriteRequest {
pub path: String,
}
#[derive(Debug, Serialize)]
pub struct MetadataResponse {
pub created: Option<i64>,
pub modified: Option<i64>,
pub size: u64,
pub exif: Option<ExifMetadata>,
pub filename_date: Option<i64>, // Date extracted from filename
pub library_id: Option<i32>,
pub library_name: Option<String>,
/// The capture wall clock to display, naive ISO-8601
/// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it
/// verbatim; never zone-convert it (ADR-0001).
pub wall_clock: Option<String>,
/// True instant of capture (unix seconds) — `date_view::file_true_instant`.
/// For instant-to-instant matching (SMS / location windows); never
/// render it, render `wall_clock`.
pub true_instant: Option<i64>,
}
impl From<fs::Metadata> for MetadataResponse {
fn from(metadata: fs::Metadata) -> Self {
MetadataResponse {
created: metadata.created().ok().map(|created| {
let utc: DateTime<Utc> = created.into();
utc.timestamp()
}),
modified: metadata.modified().ok().map(|modified| {
let utc: DateTime<Utc> = modified.into();
utc.timestamp()
}),
size: metadata.len(),
exif: None,
filename_date: None, // Will be set in endpoint handler
library_id: None,
library_name: None,
wall_clock: None, // Set in the endpoint handler
true_instant: None, // Set in the endpoint handler
}
}
}
#[derive(Debug, Serialize)]
pub struct ExifMetadata {
pub camera: Option<CameraInfo>,
pub image_properties: Option<ImageProperties>,
pub gps: Option<GpsCoordinates>,
pub capture_settings: Option<CaptureSettings>,
pub date_taken: Option<i64>,
/// Which step of the canonical-date waterfall populated `date_taken`:
/// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when
/// `date_taken` itself is NULL.
pub date_taken_source: Option<String>,
/// True UTC instant under the new date contract (ADR-0001). NULL while
/// the row is still on the legacy convention (drain pending, or the
/// unclassified filename branch).
pub date_utc: Option<i64>,
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
pub camera_offset_minutes: Option<i32>,
/// Why the offset is what it is: `offset_tag` | `gps` |
/// `assumed_server` | `utc_direct` | `manual`.
pub date_taken_tz_source: Option<String>,
/// When `date_taken_source = "manual"`, the prior `date_taken` snapshot.
/// Used by the UI's revert affordance and to label "manually overridden;
/// originally X" in the details modal.
pub original_date_taken: Option<i64>,
/// When `date_taken_source = "manual"`, the prior source.
pub original_date_taken_source: Option<String>,
/// When `date_taken_source = "manual"`, the prior `date_utc` snapshot
/// (the new-contract triple the revert restores alongside the legacy
/// pair above).
pub original_date_utc: Option<i64>,
/// When `date_taken_source = "manual"`, the prior camera offset.
pub original_camera_offset_minutes: Option<i32>,
/// When `date_taken_source = "manual"`, the prior tz source.
pub original_date_taken_tz_source: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CameraInfo {
pub make: Option<String>,
pub model: Option<String>,
pub lens: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ImageProperties {
pub width: Option<i32>,
pub height: Option<i32>,
pub orientation: Option<i32>,
}
#[derive(Debug, Serialize)]
pub struct GpsCoordinates {
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub altitude: Option<f64>,
}
#[derive(Debug, Serialize)]
pub struct CaptureSettings {
pub focal_length: Option<f64>,
pub aperture: Option<f64>,
pub shutter_speed: Option<String>,
pub iso: Option<i32>,
}
impl From<ImageExif> for ExifMetadata {
fn from(exif: ImageExif) -> Self {
let has_camera_info =
exif.camera_make.is_some() || exif.camera_model.is_some() || exif.lens_model.is_some();
let has_image_properties =
exif.width.is_some() || exif.height.is_some() || exif.orientation.is_some();
let has_gps = exif.gps_latitude.is_some()
|| exif.gps_longitude.is_some()
|| exif.gps_altitude.is_some();
let has_capture_settings = exif.focal_length.is_some()
|| exif.aperture.is_some()
|| exif.shutter_speed.is_some()
|| exif.iso.is_some();
ExifMetadata {
camera: if has_camera_info {
Some(CameraInfo {
make: exif.camera_make,
model: exif.camera_model,
lens: exif.lens_model,
})
} else {
None
},
image_properties: if has_image_properties {
Some(ImageProperties {
width: exif.width,
height: exif.height,
orientation: exif.orientation,
})
} else {
None
},
gps: if has_gps {
Some(GpsCoordinates {
latitude: exif.gps_latitude.map(|v| v as f64),
longitude: exif.gps_longitude.map(|v| v as f64),
altitude: exif.gps_altitude.map(|v| v as f64),
})
} else {
None
},
capture_settings: if has_capture_settings {
Some(CaptureSettings {
focal_length: exif.focal_length.map(|v| v as f64),
aperture: exif.aperture.map(|v| v as f64),
shutter_speed: exif.shutter_speed,
iso: exif.iso,
})
} else {
None
},
date_taken: exif.date_taken,
date_taken_source: exif.date_taken_source,
date_utc: exif.date_utc,
camera_offset_minutes: exif.camera_offset_minutes,
date_taken_tz_source: exif.date_taken_tz_source,
original_date_taken: exif.original_date_taken,
original_date_taken_source: exif.original_date_taken_source,
original_date_utc: exif.original_date_utc,
original_camera_offset_minutes: exif.original_camera_offset_minutes,
original_date_taken_tz_source: exif.original_date_taken_tz_source,
}
}
}
#[derive(Debug, Deserialize)]
pub struct AddTagRequest {
pub file_name: String,
pub tag_name: String,
}
#[derive(Deserialize)]
pub struct GetTagsRequest {
pub path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct GpsPhotoSummary {
pub path: String,
pub lat: f64,
pub lon: f64,
/// Legacy mixed-convention date (frozen for existing rows; ADR-0001).
pub date_taken: Option<i64>,
/// True instant (epoch seconds), when the row has been converted.
pub date_utc: Option<i64>,
/// Camera offset in minutes from UTC at capture, when known.
pub camera_offset_minutes: Option<i32>,
/// Which evidence pinned the instant (`offset_tag`, `gps`,
/// `assumed_server`, `utc_direct`, `manual`).
pub date_taken_tz_source: Option<String>,
/// Capture wall clock to display (naive ISO-8601; see
/// `MetadataResponse::wall_clock`).
pub wall_clock: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct GpsPhotosResponse {
pub photos: Vec<GpsPhotoSummary>,
pub total: usize,
}
/// Single-row projection of `image_exif` rich enough to drive Apollo's
/// photo-to-track matcher (and any similar window-scoped consumer) without
/// a per-file `/image/metadata` round-trip. Returned by `/photos/exif`.
#[derive(Debug, Serialize)]
pub struct ExifSummary {
pub file_path: String,
pub library_id: i32,
pub library_name: Option<String>,
pub camera_model: Option<String>,
pub width: Option<i32>,
pub height: Option<i32>,
pub gps_latitude: Option<f64>,
pub gps_longitude: Option<f64>,
pub date_taken: Option<i64>,
/// True UTC instant under the new date contract (ADR-0001). NULL while
/// the row is still on the legacy convention.
pub date_utc: Option<i64>,
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
pub camera_offset_minutes: Option<i32>,
/// How the offset was determined. See `camera_tz::TzSource`.
pub date_taken_tz_source: Option<String>,
/// True instant of capture (unix seconds) — `date_view::row_true_instant`:
/// `date_utc` when converted, else the legacy value under its per-source
/// convention, so unconverted and permanent-floor rows still match
/// instant-to-instant. For matching (location track, SMS windows); never
/// render it, render `wall_clock`.
pub true_instant: Option<i64>,
/// Capture wall clock to display (naive ISO-8601; see
/// `MetadataResponse::wall_clock`).
pub wall_clock: Option<String>,
}
impl ExifSummary {
/// Project an `image_exif` row onto the `/photos/exif` wire shape.
/// `true_instant` / `wall_clock` go through the same `date_view` reads
/// as `/image/metadata` and `/photos/gps-summary`, so all three agree.
pub fn from_row(
r: ImageExif,
library_name: Option<String>,
display_zone: chrono_tz::Tz,
server_zone: Option<chrono_tz::Tz>,
) -> Self {
ExifSummary {
true_instant: crate::date_view::row_true_instant(&r, display_zone, server_zone),
wall_clock: crate::date_view::row_wall_clock(&r, display_zone, server_zone),
library_name,
file_path: r.file_path,
library_id: r.library_id,
camera_model: r.camera_model,
width: r.width,
height: r.height,
// image_exif stores GPS as f32 to keep row size small; widen
// for the JSON shape so clients don't need to know about the
// on-disk precision.
gps_latitude: r.gps_latitude.map(f64::from),
gps_longitude: r.gps_longitude.map(f64::from),
date_taken: r.date_taken,
date_utc: r.date_utc,
camera_offset_minutes: r.camera_offset_minutes,
date_taken_tz_source: r.date_taken_tz_source,
}
}
}
#[derive(Debug, Serialize)]
pub struct ExifBatchResponse {
pub photos: Vec<ExifSummary>,
pub total: usize,
}
#[derive(Debug, Deserialize)]
pub struct ExifBatchRequest {
/// Lower bound (inclusive) on the row's true instant (`true_instant`),
/// unix seconds. Same semantics as `date_from` on `/photos`.
pub date_from: Option<i64>,
/// Upper bound (inclusive). Same semantics as `date_to` on `/photos`.
pub date_to: Option<i64>,
/// Calendar-day bounds (`YYYY-MM-DD`) in the owner's display zone; each
/// replaces its instant counterpart. Same as on `/photos`.
pub date_from_day: Option<String>,
pub date_to_day: Option<String>,
/// Restrict results to a single library by id. Omit (or "" / "all") for
/// union mode — the default. Filtered post-query in the handler so the
/// existing `query_by_exif` DAO trait stays untouched.
pub library: Option<String>,
}
#[derive(Deserialize)]
pub struct PreviewClipRequest {
pub path: String,
}
#[derive(Deserialize)]
pub struct PreviewStatusRequest {
pub paths: Vec<String>,
}
#[derive(Serialize)]
pub struct PreviewStatusResponse {
pub previews: Vec<PreviewStatusItem>,
}
#[derive(Serialize)]
pub struct PreviewStatusItem {
pub path: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview_url: Option<String>,
}
#[cfg(test)]
mod tests {
use super::Claims;
use jsonwebtoken::errors::ErrorKind;
use std::str::FromStr;
#[test]
fn test_token_from_claims() {
let claims = Claims {
exp: 16136164790, // 2481-ish
sub: String::from("9"),
};
let c = Claims::from_str(
"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5IiwiZXhwIjoxNjEzNjE2NDc5MH0.9wwK4l8vhvq55YoueEljMbN_5uVTaAsGLLRPr0AuymE")
.unwrap();
assert_eq!(claims.sub, c.sub);
assert_eq!(claims.exp, c.exp);
}
#[test]
fn test_expired_token() {
let err = Claims::from_str(
"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5IiwiZXhwIjoxNn0.eZnfaNfiD54VMbphIqeBICeG9SzAtwNXntLwtTBihjY",
);
match err.unwrap_err().into_kind() {
ErrorKind::ExpiredSignature => {}
kind => panic!("Unexpected error: {:?}", kind),
}
}
#[test]
fn test_junk_token_is_invalid() {
let err = Claims::from_str("uni-֍ՓՓՓՓՓՓՓՓՓՓՓՓՓՓՓ");
match err.unwrap_err().into_kind() {
ErrorKind::InvalidToken => {}
kind => panic!("Unexpected error: {:?}", kind),
}
}
#[test]
fn test_preview_clip_request_deserialize() {
use super::PreviewClipRequest;
let json = r#"{"path":"photos/2024/video.mp4"}"#;
let req: PreviewClipRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.path, "photos/2024/video.mp4");
}
#[test]
fn test_preview_status_request_deserialize() {
use super::PreviewStatusRequest;
let json = r#"{"paths":["a/one.mp4","b/two.mp4","c/three.mp4"]}"#;
let req: PreviewStatusRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.paths.len(), 3);
assert_eq!(req.paths[0], "a/one.mp4");
assert_eq!(req.paths[2], "c/three.mp4");
}
#[test]
fn test_preview_status_response_serialize() {
use super::{PreviewStatusItem, PreviewStatusResponse};
let response = PreviewStatusResponse {
previews: vec![
PreviewStatusItem {
path: "a/one.mp4".to_string(),
status: "complete".to_string(),
preview_url: Some("/video/preview?path=a%2Fone.mp4".to_string()),
},
PreviewStatusItem {
path: "b/two.mp4".to_string(),
status: "pending".to_string(),
preview_url: None,
},
],
};
let json = serde_json::to_value(&response).unwrap();
let previews = json["previews"].as_array().unwrap();
assert_eq!(previews.len(), 2);
// Complete item should have preview_url
assert_eq!(previews[0]["status"], "complete");
assert!(previews[0]["preview_url"].is_string());
// Pending item should not have preview_url (skip_serializing_if)
assert_eq!(previews[1]["status"], "pending");
assert!(previews[1].get("preview_url").is_none());
}
}
#[cfg(test)]
mod date_contract_tests {
use super::*;
fn sample_row() -> crate::database::models::ImageExif {
crate::database::models::ImageExif {
id: 1,
library_id: 1,
file_path: "2024/vacation/IMG_0001.jpg".to_string(),
camera_make: None,
camera_model: Some("NIKON Z 8".to_string()),
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,
// Wall 2024-07-04 12:00:00 stored legacy-style (as-UTC).
date_taken: Some(1_720_094_400),
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source: Some("exif".to_string()),
original_date_taken: None,
original_date_taken_source: None,
clip_embedding: None,
clip_model_version: None,
date_utc: Some(1_720_108_800),
camera_offset_minutes: Some(-240),
date_taken_tz_source: Some("offset_tag".to_string()),
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
}
}
/// /image/metadata contract: the new-contract date fields ride along on
/// the EXIF metadata payload alongside the legacy `date_taken`.
#[test]
fn exif_metadata_response_includes_date_contract_fields() {
let meta = ExifMetadata::from(sample_row());
assert_eq!(meta.date_taken, Some(1_720_094_400));
assert_eq!(meta.date_utc, Some(1_720_108_800));
assert_eq!(meta.camera_offset_minutes, Some(-240));
assert_eq!(meta.date_taken_tz_source.as_deref(), Some("offset_tag"));
}
/// /photos/exif contract: Apollo's per-photo summary carries the same
/// fields so the viewer flip (spec #113) can read one shape, plus the
/// server-computed `true_instant` / `wall_clock` it matches and renders.
#[test]
fn exif_summary_shape_includes_date_contract_fields() {
let summary = ExifSummary::from_row(
sample_row(),
Some("main".to_string()),
chrono_tz::America::New_York,
Some(chrono_tz::America::New_York),
);
// Verify the JSON wire names clients (Apollo, FVR) will consume.
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["date_taken"], 1_720_094_400);
assert_eq!(json["date_utc"], 1_720_108_800);
assert_eq!(json["camera_offset_minutes"], -240);
assert_eq!(json["date_taken_tz_source"], "offset_tag");
assert_eq!(json["true_instant"], 1_720_108_800);
assert_eq!(json["wall_clock"], "2024-07-04T12:00:00");
assert_eq!(json["library_name"], "main");
}
/// A row the drain hasn't converted (or never will — the edited/exported
/// floor) has no `date_utc`, but `/photos/exif` must still hand clients
/// a true instant and wall clock via the per-source fallback, so they
/// never re-derive the legacy convention themselves (ADR-0001).
#[test]
fn exif_summary_unconverted_row_falls_back_to_legacy_convention() {
let mut row = sample_row();
row.date_utc = None;
row.camera_offset_minutes = None;
row.date_taken_tz_source = None;
let summary = ExifSummary::from_row(
row,
None,
chrono_tz::America::New_York,
Some(chrono_tz::America::New_York),
);
assert_eq!(summary.date_utc, None);
// exif legacy = 12:00 wall as-UTC; read in the server zone (EDT)
// that's 16:00 UTC — the same instant the converted row names.
assert_eq!(summary.true_instant, Some(1_720_108_800));
assert_eq!(summary.wall_clock.as_deref(), Some("2024-07-04T12:00:00"));
}
}