//! Integration tests for the search handler against a real Postgres.
//!
//! Skipped silently unless `DMS_TEST_DATABASE_URL` is set. Each test creates
//! a unique schema, runs migrations into it, exercises the code, then drops
//! the schema. Run with:
//!
//! DMS_TEST_DATABASE_URL=postgres://dms_user:dms_password@localhost:5433/dms cargo test --test search_integration -- --nocapture
use chrono::Utc;
use migration::MigratorTrait;
use sea_orm::{ActiveModelTrait, ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Set, Statement};
use std::sync::atomic::{AtomicUsize, Ordering};
use uuid::Uuid;
use dms::entities::{tags as tags_entity, uploads, users};
use dms::handlers::uploads::{run_search_for_user, SearchSources};
use dms::tags as tagsvc;
static SCHEMA_COUNTER: AtomicUsize = AtomicUsize::new(0);
fn test_db_url() -> Option<String> {
std::env::var("DMS_TEST_DATABASE_URL").ok()
}
/// Create a unique schema and connect with `search_path` set to it. The
/// returned guard drops the schema on drop.
async fn fresh_schema(base_url: &str) -> (DatabaseConnection, SchemaGuard) {
// Connect to default schema to create the new one.
let admin = Database::connect(base_url).await.expect("admin connect");
let pid = std::process::id();
let n = SCHEMA_COUNTER.fetch_add(1, Ordering::SeqCst);
let schema = format!("dms_test_{pid}_{n}");
admin
.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
format!("CREATE SCHEMA \"{schema}\""),
))
.await
.expect("create schema");
drop(admin);
let mut opts = ConnectOptions::new(base_url.to_string());
opts.set_schema_search_path(schema.clone());
let db = Database::connect(opts).await.expect("schema connect");
migration::Migrator::up(&db, None).await.expect("migrate");
(db, SchemaGuard { base_url: base_url.to_string(), schema })
}
struct SchemaGuard {
base_url: String,
schema: String,
}
impl Drop for SchemaGuard {
fn drop(&mut self) {
let url = self.base_url.clone();
let schema = self.schema.clone();
// Drop in a blocking thread because Drop can't be async.
let _ = std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
if let Ok(db) = Database::connect(&url).await {
let _ = db
.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
format!("DROP SCHEMA \"{schema}\" CASCADE"),
))
.await;
}
});
})
.join();
}
}
// Ids are UUIDv7s the application assigns (the columns have no default), so
// the fixtures set them the same way.
async fn make_user(db: &DatabaseConnection, username: &str) -> Uuid {
let now = Utc::now();
let user = users::ActiveModel {
id: Set(Uuid::now_v7()),
username: Set(username.to_string()),
email: Set(format!("{username}@test")),
password_hash: Set("x".into()),
is_active: Set(true),
created_at: Set(now.into()),
updated_at: Set(now.into()),
last_login_at: Set(None),
..Default::default()
};
user.insert(db).await.expect("insert user").id
}
async fn make_upload(db: &DatabaseConnection, user_id: Uuid, file_name: &str) -> Uuid {
let now = Utc::now();
let m = uploads::ActiveModel {
id: Set(Uuid::now_v7()),
user_id: Set(user_id),
file_name_original: Set(file_name.to_string()),
file_name_generated: Set(format!("gen/{file_name}")),
file_size_bytes: Set(123),
content_type: Set(Some("image/jpeg".into())),
file_hash: Set(Some(format!("hash-{file_name}"))),
item_name: Set(None),
item_description: Set(None),
item_path: Set(format!("path/{file_name}")),
storage_backend: Set("local".into()),
processing_status: Set("pending".into()),
processing_error: Set(None),
date_uploaded: Set(now.into()),
date_updated: Set(now.into()),
..Default::default()
};
m.insert(db).await.expect("insert upload").id
}
async fn count_tags(db: &DatabaseConnection) -> u64 {
use sea_orm::EntityTrait;
use sea_orm::PaginatorTrait;
tags_entity::Entity::find().count(db).await.unwrap()
}
#[tokio::test]
async fn search_finds_namespaced_tag_by_full_query() {
let Some(url) = test_db_url() else {
eprintln!("skipping: DMS_TEST_DATABASE_URL not set");
return;
};
let (db, _guard) = fresh_schema(&url).await;
let user_id = make_user(&db, "alice").await;
let upload_id = make_upload(&db, user_id, "cover.jpg").await;
// Tag exactly as the upload form's parser would.
let parsed = tagsvc::parse_tags("species:macro");
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].namespace.as_deref(), Some("species"));
assert_eq!(parsed[0].value, "macro");
tagsvc::attach_tags(&db, upload_id, &parsed, None).await.unwrap();
assert_eq!(count_tags(&db).await, 1, "tag persisted");
// Default sources = all on; query "species:macro" should hit the tag.
let result = run_search_for_user(&db, user_id, "species:macro", &SearchSources::all_on(), 1)
.await
.expect("search");
assert_eq!(result.total, 1, "namespaced query 'species:macro' should find the tagged upload");
assert_eq!(result.rows.len(), 1);
assert_eq!(result.rows[0].id, upload_id);
let m = result.matched_tags.get(&upload_id).expect("matched tags carried for the row");
assert_eq!(m.len(), 1);
assert_eq!(m[0].namespace.as_deref(), Some("species"));
assert_eq!(m[0].tag_value, "macro");
}
#[tokio::test]
async fn search_finds_namespaced_tag_by_value_only() {
let Some(url) = test_db_url() else {
eprintln!("skipping: DMS_TEST_DATABASE_URL not set");
return;
};
let (db, _guard) = fresh_schema(&url).await;
let user_id = make_user(&db, "bob").await;
let upload_id = make_upload(&db, user_id, "frog.jpg").await;
tagsvc::attach_tags(&db, upload_id, &tagsvc::parse_tags("species:macro"), None).await.unwrap();
let result = run_search_for_user(&db, user_id, "macro", &SearchSources::all_on(), 1)
.await
.expect("search");
assert_eq!(result.total, 1, "bare query 'macro' should find species:macro via value match");
}
#[tokio::test]
async fn search_respects_other_ns_toggle() {
let Some(url) = test_db_url() else {
eprintln!("skipping: DMS_TEST_DATABASE_URL not set");
return;
};
let (db, _guard) = fresh_schema(&url).await;
let user_id = make_user(&db, "carol").await;
let upload_id = make_upload(&db, user_id, "img.jpg").await;
tagsvc::attach_tags(&db, upload_id, &tagsvc::parse_tags("species:macro"), None).await.unwrap();
// With other_ns OFF, the species namespace should be excluded.
let mut s = SearchSources::all_on();
s.other_ns = false;
let result = run_search_for_user(&db, user_id, "species:macro", &s, 1)
.await
.expect("search");
assert_eq!(result.total, 0, "other_ns off must exclude 'species' namespace");
}
#[tokio::test]
async fn search_isolates_per_user() {
let Some(url) = test_db_url() else {
eprintln!("skipping: DMS_TEST_DATABASE_URL not set");
return;
};
let (db, _guard) = fresh_schema(&url).await;
let alice = make_user(&db, "alice2").await;
let bob = make_user(&db, "bob2").await;
let alice_upload = make_upload(&db, alice, "a.jpg").await;
let _bob_upload = make_upload(&db, bob, "b.jpg").await;
// Same global tag attached to alice's upload only.
tagsvc::attach_tags(&db, alice_upload, &tagsvc::parse_tags("species:macro"), None).await.unwrap();
let alice_r = run_search_for_user(&db, alice, "species:macro", &SearchSources::all_on(), 1).await.unwrap();
let bob_r = run_search_for_user(&db, bob, "species:macro", &SearchSources::all_on(), 1).await.unwrap();
assert_eq!(alice_r.total, 1);
assert_eq!(bob_r.total, 0, "tags are global but matches must scope to the searcher's uploads");
}
#[tokio::test]
async fn search_namespace_wildcard_returns_all_uploads_in_ns() {
let Some(url) = test_db_url() else {
eprintln!("skipping: DMS_TEST_DATABASE_URL not set");
return;
};
let (db, _guard) = fresh_schema(&url).await;
let user_id = make_user(&db, "wendy").await;
let u1 = make_upload(&db, user_id, "moss.jpg").await;
let u2 = make_upload(&db, user_id, "frog.jpg").await;
let u3 = make_upload(&db, user_id, "unrelated.jpg").await;
tagsvc::attach_tags(&db, u1, &tagsvc::parse_tags("species:macro"), None).await.unwrap();
tagsvc::attach_tags(&db, u2, &tagsvc::parse_tags("species:bird"), None).await.unwrap();
tagsvc::attach_tags(&db, u3, &tagsvc::parse_tags("Series:Friends"), None).await.unwrap();
let result = run_search_for_user(&db, user_id, "species:*", &SearchSources::all_on(), 1)
.await
.expect("search");
assert_eq!(result.total, 2, "wildcard ns should match all tags in 'species'");
let ids: std::collections::HashSet<Uuid> = result.rows.iter().map(|r| r.id).collect();
assert!(ids.contains(&u1));
assert!(ids.contains(&u2));
assert!(!ids.contains(&u3));
// Each matched upload must carry the matching tag(s) for UI display.
let m1 = result.matched_tags.get(&u1).expect("u1 matched tag");
assert!(m1.iter().any(|t| t.tag_value == "macro" && t.namespace.as_deref() == Some("species")));
let m2 = result.matched_tags.get(&u2).expect("u2 matched tag");
assert!(m2.iter().any(|t| t.tag_value == "bird" && t.namespace.as_deref() == Some("species")));
}
#[tokio::test]
async fn search_user_added_tag_via_upload_form_then_search() {
// Reproduces the full path: parse_tags + attach_tags + run_search,
// matching exactly what handlers/uploads.rs does on the upload form.
let Some(url) = test_db_url() else {
eprintln!("skipping: DMS_TEST_DATABASE_URL not set");
return;
};
let (db, _guard) = fresh_schema(&url).await;
let user_id = make_user(&db, "dave").await;
let upload_id = make_upload(&db, user_id, "moss.jpg").await;
let raw = "species:macro Series:Friends extra";
let parsed = tagsvc::parse_tags(raw);
tagsvc::attach_tags(&db, upload_id, &parsed, None).await.unwrap();
for (q, expected, why) in [
("species:macro", 1u64, "exact namespaced query"),
("Series:Friends", 1, "case-sensitive namespaced query"),
("series:friends", 0, "lowercase namespace doesn't match capitalized"),
("extra", 1, "bare value tag"),
("nope", 0, "no match"),
] {
let r = run_search_for_user(&db, user_id, q, &SearchSources::all_on(), 1)
.await
.expect("search");
assert_eq!(r.total, expected, "{why}");
}
}
⭐
Log in to mark this repository.
10.8 KiB · text
History
1a5b596