840 lines
29 KiB
Rust
840 lines
29 KiB
Rust
//! 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<AppState>,
|
|
conn: Arc<Mutex<SqliteConnection>>,
|
|
exif: Data<Mutex<Box<dyn ExifDao>>>,
|
|
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::<Text, _>(&library.root_path)
|
|
.execute(&mut *conn.lock().unwrap())
|
|
.unwrap();
|
|
Fx {
|
|
exif: Data::new(Mutex::new(
|
|
Box::new(SqliteExifDao::from_shared(conn.clone())) as Box<dyn ExifDao>,
|
|
)),
|
|
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::<Text, _>(rel)
|
|
.bind::<Text, _>(&hash)
|
|
.execute(&mut *self.conn.lock().unwrap())
|
|
.unwrap();
|
|
hash
|
|
}
|
|
|
|
fn forget(&self, rel: &str) {
|
|
sql_query("DELETE FROM image_exif WHERE rel_path = ?")
|
|
.bind::<Text, _>(rel)
|
|
.execute(&mut *self.conn.lock().unwrap())
|
|
.unwrap();
|
|
}
|
|
|
|
fn exif_arc(&self) -> Arc<Mutex<Box<dyn ExifDao>>> {
|
|
Arc::new(Mutex::new(
|
|
Box::new(SqliteExifDao::from_shared(self.conn.clone())) as Box<dyn ExifDao>,
|
|
))
|
|
}
|
|
|
|
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::<N>(&mut *self.conn.lock().unwrap())
|
|
.unwrap()
|
|
.n
|
|
}
|
|
}
|
|
|
|
fn dao_listing(fx: &Fx, include_versions: bool) -> Vec<String> {
|
|
let mut v: Vec<String> = 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/<name>.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::<SqliteTagDao, RealFileSystem>)),
|
|
)
|
|
.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::<serde_json::Value>(&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<dyn ExifDao>| {
|
|
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<String> = 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);
|
|
}
|