//! 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); }