From 8b7c517a17c498b7d9989a45680132d149ea14f0 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 30 Oct 2026 11:33:47 -0400 Subject: [PATCH] test(versions): ingest linking, listings, endpoints, tag binding, lifecycle Co-Authored-By: Claude Opus 5.5 --- src/database/mod.rs | 8 + src/versions/integration_tests.rs | 839 ++++++++++++++++++++++++++++++ src/versions/mod.rs | 41 ++ 3 files changed, 888 insertions(+) create mode 100644 src/versions/integration_tests.rs diff --git a/src/database/mod.rs b/src/database/mod.rs index 8939efe..bdbfc3f 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -307,6 +307,14 @@ impl SqliteFavoriteDao { } } +#[cfg(test)] +impl SqliteFavoriteDao { + /// Test-only constructor sharing an already-wrapped connection. + pub fn from_shared(connection: Arc>) -> Self { + SqliteFavoriteDao { connection } + } +} + impl FavoriteDao for SqliteFavoriteDao { fn add_favorite(&mut self, user_id: i32, favorite_path: &str) -> Result { use schema::favorites::dsl::*; diff --git a/src/versions/integration_tests.rs b/src/versions/integration_tests.rs new file mode 100644 index 0000000..a97296f --- /dev/null +++ b/src/versions/integration_tests.rs @@ -0,0 +1,839 @@ +//! End-to-end checks for photo versions (#191): linking at ingest, hiding in +//! listings, the HTTP surface, tag/favorite binding and the lifecycle rules. +//! Everything runs against one shared in-memory SQLite and a temp library +//! root; nothing reads a real library. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use actix_web::test as awtest; +use actix_web::web::Data; +use actix_web::{App, web}; +use diesel::prelude::*; +use diesel::sql_query; +use diesel::sql_types::{BigInt, Text}; +use diesel::sqlite::SqliteConnection; + +use super::xmp::test_png::{producer_xmp, write_png}; +use super::*; +use crate::data::Claims; +use crate::database::test::in_memory_db_connection; +use crate::database::{ExifDao, FavoriteDao, SqliteExifDao, SqliteFavoriteDao, SqliteVersionDao}; +use crate::files::{RealFileSystem, list_photos}; +use crate::library_maintenance; +use crate::state::AppState; +use crate::tags::{SqliteTagDao, TagDao}; + +const LIB: i32 = 1; + +struct Fx { + state: Data, + conn: Arc>, + exif: Data>>, + library: Library, + root: PathBuf, +} + +fn fx() -> Fx { + let mut state = AppState::test_state(); + let conn = Arc::new(Mutex::new(in_memory_db_connection())); + state.version_dao = Arc::new(Mutex::new(Box::new(SqliteVersionDao::from_shared( + conn.clone(), + )))); + let library = state.primary_library().clone(); + let root = PathBuf::from(&library.root_path); + std::fs::create_dir_all(&root).unwrap(); + // The migration seeds library 1 with a placeholder root. + sql_query("UPDATE libraries SET root_path = ? WHERE id = 1") + .bind::(&library.root_path) + .execute(&mut *conn.lock().unwrap()) + .unwrap(); + Fx { + exif: Data::new(Mutex::new( + Box::new(SqliteExifDao::from_shared(conn.clone())) as Box, + )), + state: Data::new(state), + conn, + library, + root, + } +} + +impl Fx { + fn exec(&self, sql: &str) { + sql_query(sql) + .execute(&mut *self.conn.lock().unwrap()) + .unwrap_or_else(|e| panic!("{sql}: {e}")); + } + + /// Write a file under the library root and register it in `image_exif` + /// with its real content hash. Returns the hash. + fn put(&self, rel: &str, bytes: &[u8]) -> String { + let abs = self.root.join(rel); + std::fs::create_dir_all(abs.parent().unwrap()).unwrap(); + std::fs::write(&abs, bytes).unwrap(); + self.register(rel) + } + + fn put_png(&self, rel: &str, xmp: Option<&str>, salt: u8) -> String { + write_png(&self.root.join(rel), xmp, None, salt); + self.register(rel) + } + + fn register(&self, rel: &str) -> String { + let hash = crate::content_hash::compute(&self.root.join(rel)) + .unwrap() + .content_hash; + sql_query( + "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash) \ + VALUES (1, ?, 0, 0, ?)", + ) + .bind::(rel) + .bind::(&hash) + .execute(&mut *self.conn.lock().unwrap()) + .unwrap(); + hash + } + + fn forget(&self, rel: &str) { + sql_query("DELETE FROM image_exif WHERE rel_path = ?") + .bind::(rel) + .execute(&mut *self.conn.lock().unwrap()) + .unwrap(); + } + + fn exif_arc(&self) -> Arc>> { + Arc::new(Mutex::new( + Box::new(SqliteExifDao::from_shared(self.conn.clone())) as Box, + )) + } + + fn link(&self, version_hash: &str, original_hash: &str, rel: &str) { + let outcome = self + .state + .version_dao + .lock() + .unwrap() + .link_version(&NewVersionLink { + version_hash: version_hash.into(), + original_hash: original_hash.into(), + kind: "restored".into(), + source: "xmp".into(), + library_id: LIB, + rel_path: rel.into(), + references: vec![], + }) + .unwrap(); + assert_eq!(outcome, LinkOutcome::Linked); + } + + fn token() -> String { + jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &Claims::valid_user("1".to_string()), + &jsonwebtoken::EncodingKey::from_secret(b"test_key"), + ) + .unwrap() + } + + fn count(&self, sql: &str) -> i64 { + #[derive(QueryableByName)] + struct N { + #[diesel(sql_type = BigInt)] + n: i64, + } + sql_query(sql) + .get_result::(&mut *self.conn.lock().unwrap()) + .unwrap() + .n + } +} + +fn dao_listing(fx: &Fx, include_versions: bool) -> Vec { + let mut v: Vec = fx + .exif + .lock() + .unwrap() + .list_rel_paths_for_libraries(&[], None, false, include_versions) + .unwrap() + .into_iter() + .map(|(_, p)| p) + .collect(); + v.sort(); + v +} + +// ── Ingest ────────────────────────────────────────────────────────────── + +#[actix_rt::test] +async fn xmp_link_is_made_at_ingest_and_survives_a_rename() { + let fx = fx(); + let original = fx.put("2004/scan_0042.jpg", b"original bytes"); + let xmp = producer_xmp(&original, "restored", &[]); + // The producer saved it as `aiedits/.restored.png`; here it was + // renamed and moved somewhere the name convention can't explain. + fx.put_png("somewhere/else/renamed copy.png", Some(&xmp), 1); + + let files = vec![( + fx.root.join("somewhere/else/renamed copy.png"), + "somewhere/else/renamed copy.png".to_string(), + )]; + let linked = link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao); + assert_eq!(linked, 1); + + let (vh, _) = { + let row = fx + .exif + .lock() + .unwrap() + .get_exif("somewhere/else/renamed copy.png") + .unwrap() + .unwrap(); + (row.content_hash.unwrap(), ()) + }; + let link = fx + .state + .version_dao + .lock() + .unwrap() + .get_link(&vh) + .unwrap() + .unwrap(); + assert_eq!(link.original_hash, original); + assert_eq!(link.kind, "restored"); + assert_eq!(link.source, "xmp"); +} + +#[actix_rt::test] +async fn xmp_references_are_provenance_only() { + let fx = fx(); + let original = fx.put("a/main.jpg", b"main"); + let other = fx.put("a/other-view.jpg", b"another view of the subject"); + let xmp = producer_xmp(&original, "edit", &[&other]); + let vh = fx.put_png("a/aiedits/main.jpg.edit.png", Some(&xmp), 2); + let files = vec![( + fx.root.join("a/aiedits/main.jpg.edit.png"), + "a/aiedits/main.jpg.edit.png".to_string(), + )]; + assert_eq!( + link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao), + 1 + ); + let mut dao = fx.state.version_dao.lock().unwrap(); + assert_eq!(dao.get_link(&vh).unwrap().unwrap().original_hash, original); + assert_eq!(dao.list_references(&vh).unwrap(), vec![other.clone()]); + // The reference photo is not linked to anything and stays listed. + assert!(dao.get_link(&other).unwrap().is_none()); + drop(dao); + assert!(dao_listing(&fx, false).contains(&"a/other-view.jpg".to_string())); +} + +#[actix_rt::test] +async fn name_link_pairs_raw_and_jpeg_with_the_right_original() { + let fx = fx(); + let raw = fx.put("trip/IMG_1.NEF", b"raw bytes"); + let jpg = fx.put("trip/IMG_1.JPG", b"jpeg bytes"); + let v_raw = fx.put_png("trip/aiedits/IMG_1.NEF.restored.png", None, 3); + let v_jpg = fx.put_png("trip/aiedits/IMG_1.JPG.restored.2.png", None, 4); + + let n = sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao); + assert_eq!(n, 2); + let mut dao = fx.state.version_dao.lock().unwrap(); + let l_raw = dao.get_link(&v_raw).unwrap().unwrap(); + let l_jpg = dao.get_link(&v_jpg).unwrap().unwrap(); + assert_eq!(l_raw.original_hash, raw); + assert_eq!(l_jpg.original_hash, jpg); + assert_eq!(l_raw.source, "name"); + assert_eq!(l_raw.kind, "restored"); + drop(dao); + // Idempotent. + assert_eq!( + sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao), + 0 + ); +} + +#[actix_rt::test] +async fn name_link_waits_for_the_original_to_be_hashed() { + let fx = fx(); + let vh = fx.put_png("a/photo.jpg.upscale.png", None, 5); + // Original row exists but its hash hasn't landed yet. + fx.exec( + "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified) \ + VALUES (1, 'a/photo.jpg', 0, 0)", + ); + assert_eq!( + sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao), + 0, + "no link by path alone while the original is hash-less" + ); + let original_hash = "ab".repeat(32); + fx.exec(&format!( + "UPDATE image_exif SET content_hash = '{original_hash}' WHERE rel_path = 'a/photo.jpg'" + )); + assert_eq!( + sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao), + 1 + ); + let link = fx + .state + .version_dao + .lock() + .unwrap() + .get_link(&vh) + .unwrap() + .unwrap(); + assert_eq!(link.original_hash, original_hash); + assert_eq!(link.kind, "upscale"); +} + +// ── Listings ──────────────────────────────────────────────────────────── + +/// Build the test service: `/photos` plus the version endpoints, over the +/// fixture's shared connection. +macro_rules! photos_app { + ($fx:expr) => {{ + let fx: &Fx = &$fx; + let tag_dao = Data::new(Mutex::new(SqliteTagDao::new(fx.conn.clone()))); + let fs = Data::new(RealFileSystem::new(fx.library.root_path.clone())); + awtest::init_service( + App::new() + .service( + web::resource("/photos") + .route(web::get().to(list_photos::)), + ) + .service(super::handlers::get_versions) + .service(super::handlers::put_display) + .app_data(fx.state.clone()) + .app_data(fx.exif.clone()) + .app_data(tag_dao) + .app_data(fs), + ) + .await + }}; +} + +/// GET `uri` as an authenticated user: `(status, json body)`. +macro_rules! get_json { + ($app:expr, $uri:expr) => {{ + let req = awtest::TestRequest::get() + .uri($uri) + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .to_request(); + let resp = awtest::call_service(&$app, req).await; + let status = resp.status().as_u16(); + let body = awtest::read_body(resp).await; + ( + status, + serde_json::from_slice::(&body).unwrap_or_default(), + ) + }}; +} + +/// A photo with a restoration, one reference-less edit and an unrelated photo. +fn seeded() -> (Fx, String, String, String) { + let fx = fx(); + let original = fx.put("2004/scan.jpg", b"scan bytes"); + fx.put("2004/unrelated.jpg", b"something else"); + let v1 = fx.put_png("2004/aiedits/scan.jpg.restored.png", None, 7); + let v2 = fx.put_png("2004/aiedits/scan.jpg.edit.png", None, 8); + fx.link(&v1, &original, "2004/aiedits/scan.jpg.restored.png"); + fx.link(&v2, &original, "2004/aiedits/scan.jpg.edit.png"); + (fx, original, v1, v2) +} + +#[actix_rt::test] +async fn photos_hides_versions_and_adds_parallel_arrays() { + let (fx, _o, v1, _v2) = seeded(); + let app = photos_app!(fx); + + // Recursive (DB-backed) listing. + let (status, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + assert_eq!(status, 200); + assert_eq!( + body["photos"], + serde_json::json!(["2004/scan.jpg", "2004/unrelated.jpg"]) + ); + // No display choice yet: counts only; display paths are skipped. + assert_eq!(body["photo_version_counts"], serde_json::json!([2, 0])); + assert!(body.get("photo_display_paths").is_none()); + + // Non-recursive (filesystem walk) listing of the folder with the original. + let (_, body) = get_json!(app, "/photos?path=2004&sort=nameasc"); + assert_eq!( + body["photos"], + serde_json::json!(["2004/scan.jpg", "2004/unrelated.jpg"]) + ); + // ...and of the aiedits folder itself: the versions are hidden there too. + let (_, body) = get_json!(app, "/photos?path=2004/aiedits&sort=nameasc"); + assert_eq!(body["photos"], serde_json::json!([])); + + // include_versions lists them as photos of their own. + let (_, body) = get_json!( + app, + "/photos?path=&recursive=true&sort=nameasc&include_versions=true" + ); + assert_eq!(body["photos"].as_array().unwrap().len(), 4); + + // A global display choice shows up as photo_display_paths. + let req = awtest::TestRequest::put() + .uri("/image/versions/display") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .set_json(serde_json::json!({"path": "2004/scan.jpg", "display_hash": v1})) + .to_request(); + assert_eq!(awtest::call_service(&app, req).await.status(), 200); + let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + assert_eq!( + body["photo_display_paths"], + serde_json::json!(["2004/aiedits/scan.jpg.restored.png", null]) + ); + assert_eq!( + body["photos"][0], "2004/scan.jpg", + "photos[i] stays the original" + ); +} + +#[actix_rt::test] +async fn photos_without_versions_is_unchanged() { + let fx = fx(); + fx.put("a.jpg", b"a"); + fx.put("b.jpg", b"b"); + let app = photos_app!(fx); + let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + let keys: Vec<&String> = body.as_object().unwrap().keys().collect(); + assert!( + !keys + .iter() + .any(|k| k.starts_with("photo_display") || k.starts_with("photo_version")) + ); + assert_eq!(body["photos"], serde_json::json!(["a.jpg", "b.jpg"])); +} + +#[actix_rt::test] +async fn deleting_the_original_surfaces_the_version() { + let (fx, _o, _v1, _v2) = seeded(); + fx.forget("2004/scan.jpg"); + let app = photos_app!(fx); + let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + assert_eq!( + body["photos"], + serde_json::json!([ + "2004/aiedits/scan.jpg.edit.png", + "2004/aiedits/scan.jpg.restored.png", + "2004/unrelated.jpg" + ]) + ); + assert!(body.get("photo_version_counts").is_none()); +} + +#[actix_rt::test] +async fn library_scoped_listing_hides_only_when_the_original_is_in_view() { + let (fx, original, v1, _v2) = seeded(); + fx.exec( + "INSERT INTO libraries (id, name, root_path, created_at) VALUES (2, 'two', '/tmp/two', 0)", + ); + // Move the original into library 2. + fx.exec(&format!( + "UPDATE image_exif SET library_id = 2 WHERE content_hash = '{original}'" + )); + let lib1 = fx + .exif + .lock() + .unwrap() + .list_rel_paths_for_libraries(&[1], None, false, false) + .unwrap(); + assert!( + lib1.iter().any(|(_, p)| p.ends_with("restored.png")), + "original is outside library 1: the version stays visible" + ); + let all = dao_listing(&fx, false); + assert!(!all.iter().any(|p| p.ends_with("restored.png"))); + let _ = v1; +} + +#[actix_rt::test] +async fn exif_gps_and_memories_queries_hide_versions() { + let (fx, original, v1, _v2) = seeded(); + fx.exec(&format!( + "UPDATE image_exif SET date_taken = 1500000000, date_utc = 1500000000, \ + gps_latitude = 10.5, gps_longitude = 20.5, camera_make = 'Canon', camera_model = 'EOS', \ + date_taken_source = 'exif' WHERE content_hash = '{original}'" + )); + // Copy to the versions (what the watcher does each tick). + assert_eq!( + fx.state + .version_dao + .lock() + .unwrap() + .sync_version_metadata() + .unwrap(), + 2 + ); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{v1}' \ + AND date_utc = 1500000000 AND gps_latitude = 10.5 AND camera_make = 'Canon' \ + AND date_taken_source = 'version_of'" + )), + 1, + "a version takes the original's date, GPS and camera" + ); + + let mut dao = fx.exif.lock().unwrap(); + let gps = dao.get_all_with_gps("", true).unwrap(); + assert_eq!(gps.len(), 1, "GPS summary shows the photo once"); + assert_eq!(gps[0].file_path, "2004/scan.jpg"); + let hit = dao + .query_by_exif(None, Some("Canon"), None, None, None, None, false) + .unwrap(); + assert_eq!(hit.len(), 1); + let all = dao + .query_by_exif(None, Some("Canon"), None, None, None, None, true) + .unwrap(); + assert_eq!(all.len(), 3); + assert_eq!( + dao.get_camera_makes().unwrap(), + vec![("Canon".to_string(), 1)] + ); + // /memories: the original's day, once. + let window = |dao: &mut Box| { + dao.get_memories_in_window(1, 0, 4_000_000_000, -1, 4_000_000_000, &[]) + .unwrap() + }; + let rows = window(&mut dao); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].file_path, "2004/scan.jpg"); + drop(dao); + + // Original deleted: both versions surface on the original's date. + fx.forget("2004/scan.jpg"); + let mut dao = fx.exif.lock().unwrap(); + let rows = window(&mut dao); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| r.date_utc == Some(1_500_000_000))); +} + +#[actix_rt::test] +async fn derived_work_never_sees_version_rows() { + let (fx, original, v1, _v2) = seeded(); + // Same phash on original and version, as a restore would have. + fx.exec("UPDATE image_exif SET phash_64 = 42, dhash_64 = 7, clip_embedding = x'00000000', clip_model_version = 'm'"); + let mut dao = fx.exif.lock().unwrap(); + let perceptual = dao.list_perceptual_candidates(None, true).unwrap(); + assert!(perceptual.iter().all(|r| r.content_hash != v1)); + assert!(perceptual.iter().any(|r| r.content_hash == original)); + let idx = dao.list_clip_index(&[], None).unwrap(); + assert!(idx.iter().all(|(h, _)| *h != v1)); + drop(dao); + + fx.exec("UPDATE image_exif SET clip_embedding = NULL"); + let todo = fx + .exif + .lock() + .unwrap() + .list_clip_unencoded_candidates(1, 100) + .unwrap(); + assert!(todo.iter().all(|(_, h)| *h != v1 && !h.is_empty())); + assert_eq!(todo.len(), 2, "original + unrelated; neither version"); + let paths = fx + .exif + .lock() + .unwrap() + .list_image_paths(None, true) + .unwrap(); + assert!(paths.iter().all(|(_, p)| !p.contains("aiedits"))); +} + +// ── Endpoints ─────────────────────────────────────────────────────────── + +#[actix_rt::test] +async fn versions_endpoint_answers_for_original_or_any_version_path() { + let (fx, original, v1, v2) = seeded(); + let app = photos_app!(fx); + + for path in [ + "2004/scan.jpg", + "2004/aiedits/scan.jpg.restored.png", + "2004/aiedits/scan.jpg.edit.png", + ] { + let (status, body) = get_json!(app, &format!("/image/versions?path={path}")); + assert_eq!(status, 200, "{path}"); + assert_eq!(body["original"]["path"], "2004/scan.jpg"); + assert_eq!(body["original"]["hash"], original); + assert_eq!(body["original"]["library_id"], 1); + let versions = body["versions"].as_array().unwrap(); + assert_eq!(versions.len(), 2); + let hashes: Vec<&str> = versions + .iter() + .map(|v| v["hash"].as_str().unwrap()) + .collect(); + assert!(hashes.contains(&v1.as_str()) && hashes.contains(&v2.as_str())); + for key in [ + "path", + "library_id", + "kind", + "source", + "created_at", + "references", + ] { + assert!(versions[0].get(key).is_some(), "missing {key}"); + } + assert!(body["display_hash"].is_null()); + } + + let (status, _) = get_json!(app, "/image/versions?path=nope.jpg"); + assert_eq!(status, 404); +} + +#[actix_rt::test] +async fn display_choice_set_from_a_version_path_cleared_and_validated() { + let (fx, original, v1, _v2) = seeded(); + let app = photos_app!(fx); + let put = |body: serde_json::Value| { + awtest::TestRequest::put() + .uri("/image/versions/display") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .set_json(body) + .to_request() + }; + + // Set via a *version's* path, no library given. + let resp = awtest::call_service( + &app, + put(serde_json::json!({"path": "2004/aiedits/scan.jpg.edit.png", "display_hash": v1})), + ) + .await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = awtest::read_body_json(resp).await; + assert_eq!(body["display_hash"], v1); + + // Not a version of this photo. + let resp = awtest::call_service( + &app, + put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": "f".repeat(64)})), + ) + .await; + assert_eq!(resp.status(), 400); + + // Choosing the original clears it, as does null. + let resp = awtest::call_service( + &app, + put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": original})), + ) + .await; + let body: serde_json::Value = awtest::read_body_json(resp).await; + assert!(body["display_hash"].is_null()); + awtest::call_service( + &app, + put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": v1})), + ) + .await; + let resp = awtest::call_service( + &app, + put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": null})), + ) + .await; + let body: serde_json::Value = awtest::read_body_json(resp).await; + assert!(body["display_hash"].is_null()); + + // `library` may also ride in the query string (Apollo/FileViewer differ). + let req = awtest::TestRequest::put() + .uri("/image/versions/display?library=1") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .set_json(serde_json::json!({"path": "2004/scan.jpg", "display_hash": v1})) + .to_request(); + assert_eq!(awtest::call_service(&app, req).await.status(), 200); +} + +#[actix_rt::test] +async fn deleting_the_displayed_version_falls_back_to_the_original() { + let (fx, _o, v1, _v2) = seeded(); + fx.state + .version_dao + .lock() + .unwrap() + .set_display(&_o, Some(&v1)) + .unwrap(); + fx.forget("2004/aiedits/scan.jpg.restored.png"); + let app = photos_app!(fx); + let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc"); + assert_eq!(body["photos"][0], "2004/scan.jpg"); + assert!( + body.get("photo_display_paths").is_none(), + "falls back to the original" + ); + assert_eq!(body["photo_version_counts"][0], 1); + let (_, body) = get_json!(app, "/image/versions?path=2004/scan.jpg"); + assert!(body["display_hash"].is_null()); +} + +#[actix_rt::test] +async fn a_version_serves_its_full_file() { + let (fx, _o, _v1, _v2) = seeded(); + let app = awtest::init_service( + App::new() + .service(crate::handlers::image::get_image) + .app_data(fx.state.clone()) + .app_data(fx.exif.clone()), + ) + .await; + let req = awtest::TestRequest::get() + .uri("/image?path=2004/aiedits/scan.jpg.restored.png") + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .to_request(); + let resp = awtest::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + let on_disk = std::fs::read(fx.root.join("2004/aiedits/scan.jpg.restored.png")).unwrap(); + assert_eq!(awtest::read_body(resp).await.as_ref(), on_disk.as_slice()); +} + +// ── Tags and favorites ────────────────────────────────────────────────── + +#[actix_rt::test] +async fn tags_and_favorites_on_a_version_path_bind_to_the_original() { + let (fx, original, _v1, _v2) = seeded(); + fx.exec("INSERT INTO users (username, password) VALUES ('u', 'p')"); + let mut tags = SqliteTagDao::new(fx.conn.clone()); + let tag = tags.create_tag("holiday").unwrap(); + tags.tag_file("2004/aiedits/scan.jpg.restored.png", tag.id) + .unwrap(); + + assert_eq!( + fx.count("SELECT COUNT(*) AS n FROM tagged_photo WHERE rel_path = '2004/scan.jpg'"), + 1 + ); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM tagged_photo WHERE content_hash = '{original}'" + )), + 1, + "bound to the original's hash" + ); + // Read against either path. + for p in ["2004/scan.jpg", "2004/aiedits/scan.jpg.edit.png"] { + assert_eq!(tags.get_tags_for_path(p).unwrap().len(), 1, "{p}"); + } + // The shared-content path set (what GET /image/tags expands) covers both. + let sharing = fx + .exif + .lock() + .unwrap() + .get_rel_paths_sharing_content(1, "2004/aiedits/scan.jpg.edit.png") + .unwrap(); + assert!(sharing.contains(&"2004/scan.jpg".to_string())); + // Removing via a version path removes the original's tag. + tags.remove_tag("holiday", "2004/aiedits/scan.jpg.restored.png") + .unwrap(); + assert!(tags.get_tags_for_path("2004/scan.jpg").unwrap().is_empty()); + + let mut favs = SqliteFavoriteDao::from_shared(fx.conn.clone()); + favs.add_favorite(1, "2004/aiedits/scan.jpg.restored.png") + .unwrap(); + let listed: Vec = favs + .get_favorites(1) + .unwrap() + .into_iter() + .map(|f| f.path) + .collect(); + assert_eq!(listed, vec!["2004/scan.jpg"]); + favs.remove_favorite(1, "2004/aiedits/scan.jpg.edit.png".to_string()); + assert!(favs.get_favorites(1).unwrap().is_empty()); +} + +#[actix_rt::test] +async fn tags_bind_to_the_version_itself_once_the_original_is_gone() { + let (fx, _o, _v1, _v2) = seeded(); + fx.forget("2004/scan.jpg"); + let mut tags = SqliteTagDao::new(fx.conn.clone()); + let tag = tags.create_tag("keeper").unwrap(); + tags.tag_file("2004/aiedits/scan.jpg.edit.png", tag.id) + .unwrap(); + assert_eq!( + tags.get_tags_for_path("2004/aiedits/scan.jpg.edit.png") + .unwrap() + .len(), + 1 + ); +} + +// ── Lifecycle ─────────────────────────────────────────────────────────── + +#[actix_rt::test] +async fn orphan_gc_retires_a_gone_version_but_keeps_links_of_a_gone_original() { + let (fx, original, v1, v2) = seeded(); + fx.state + .version_dao + .lock() + .unwrap() + .set_display(&original, Some(&v1)) + .unwrap(); + + // Version v1's file disappears. + fx.forget("2004/aiedits/scan.jpg.restored.png"); + let mut conn = fx.conn.lock().unwrap(); + let mut state = library_maintenance::OrphanGcState::default(); + library_maintenance::run_orphan_gc(&mut conn, &mut state, true); + let stats = library_maintenance::run_orphan_gc(&mut conn, &mut state, true); + assert_eq!(stats.deleted_photo_versions, 2, "link + display row"); + drop(conn); + assert_eq!(fx.count("SELECT COUNT(*) AS n FROM photo_versions"), 1); + assert_eq!( + fx.count("SELECT COUNT(*) AS n FROM photo_version_display"), + 0 + ); + + // The original goes too: the remaining link is kept (the version is a + // standalone photo, and the link revives if the original returns). + fx.forget("2004/scan.jpg"); + let mut conn = fx.conn.lock().unwrap(); + for _ in 0..3 { + library_maintenance::run_orphan_gc(&mut conn, &mut state, true); + } + drop(conn); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM photo_versions WHERE version_hash = '{v2}'" + )), + 1 + ); +} + +#[actix_rt::test] +async fn back_ref_refresh_follows_a_moved_version_file() { + let (fx, _o, v1, _v2) = seeded(); + // The file moved: old row retired, new row appears elsewhere. + fx.forget("2004/aiedits/scan.jpg.restored.png"); + fx.exec(&format!( + "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash) \ + VALUES (1, 'archive/scan.jpg.restored.png', 0, 0, '{v1}')" + )); + let mut conn = fx.conn.lock().unwrap(); + assert!(library_maintenance::refresh_back_refs(&mut conn) >= 1); + drop(conn); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM photo_versions \ + WHERE version_hash = '{v1}' AND rel_path = 'archive/scan.jpg.restored.png'" + )), + 1 + ); +} + +#[actix_rt::test] +async fn face_stats_denominator_leaves_versions_out() { + let (fx, _o, _v1, _v2) = seeded(); + let hidden = fx + .state + .version_dao + .lock() + .unwrap() + .hidden_version_rows(&[]) + .unwrap(); + assert_eq!(hidden.len(), 2); + let mut faces = crate::faces::SqliteFaceDao::from_connection(fx.conn.clone()); + use crate::faces::FaceDao; + let stats = faces.stats(None).unwrap(); + // scan.jpg + unrelated.jpg; the two .png versions stay out of the + // denominator because they are never scanned. + assert_eq!(stats.total_photos, 2); +} diff --git a/src/versions/mod.rs b/src/versions/mod.rs index efbd9bd..1100890 100644 --- a/src/versions/mod.rs +++ b/src/versions/mod.rs @@ -17,6 +17,9 @@ pub mod handlers; pub mod xmp; +#[cfg(test)] +mod integration_tests; + use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex}; @@ -361,6 +364,44 @@ mod tests { assert!(parse_version_name(".restored.png").is_none()); } + #[test] + fn record_fields_are_skipped_without_versions_and_null_without_a_display_choice() { + #[derive(serde::Serialize)] + struct Rec { + path: &'static str, + #[serde(flatten)] + versions: VersionFields, + } + let json = |summary: Option<&version_dao::PhotoVersionSummary>| { + serde_json::to_value(Rec { + path: "a.jpg", + versions: VersionFields::from_summary(summary), + }) + .unwrap() + }; + assert_eq!(json(None), serde_json::json!({"path": "a.jpg"})); + let plain = version_dao::PhotoVersionSummary { + version_count: 2, + display: None, + }; + assert_eq!( + json(Some(&plain)), + serde_json::json!({"path": "a.jpg", "display_path": null, "version_count": 2}) + ); + let shown = version_dao::PhotoVersionSummary { + version_count: 1, + display: Some((1, "aiedits/a.jpg.restored.png".into())), + }; + assert_eq!( + json(Some(&shown)), + serde_json::json!({ + "path": "a.jpg", + "display_path": "aiedits/a.jpg.restored.png", + "version_count": 1 + }) + ); + } + #[test] fn candidates_prefer_the_same_dir_then_the_parent_of_aiedits() { assert_eq!(