10.7 KiB · text History c20d4f7
//! 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_raw(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_raw(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),
    };
    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()),
    };
    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}");
    }
}