From 7f4b75729082c3aeee9b123c471787f83042cd37 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Wed, 24 Dec 2025 10:00:58 -0800 Subject: [PATCH] Add comprehensive integration tests for the tag system - Introduced a new test suite for validating the tag workflow, including tag creation, application, and database persistence. - Implemented event collection to verify resource events during tag operations. - Added assertions to ensure correct tag associations with files and proper database entries. - Enhanced logging for better traceability of test execution and outcomes. --- core/tests/tag_integration_test.rs | 388 +++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 core/tests/tag_integration_test.rs diff --git a/core/tests/tag_integration_test.rs b/core/tests/tag_integration_test.rs new file mode 100644 index 000000000..e8d57db92 --- /dev/null +++ b/core/tests/tag_integration_test.rs @@ -0,0 +1,388 @@ +//! Comprehensive integration test for tag system +//! +//! This test validates the full tag workflow including: +//! - Tag creation with resource events +//! - File tagging with resource events +//! - Database persistence verification +//! - Multi-file and multi-tag operations + +mod helpers; + +use helpers::*; +use sd_core::{ + infra::{ + action::LibraryAction, + db::entities::{entry, tag, user_metadata, user_metadata_tag}, + }, + location::IndexMode, + ops::tags::{ + apply::{action::ApplyTagsAction, input::ApplyTagsInput}, + create::{action::CreateTagAction, input::CreateTagInput}, + }, +}; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter}; +use tokio::time::Duration; + +#[tokio::test] +async fn test_tag_creation_and_application_with_events() -> anyhow::Result<()> { + let harness = IndexingHarnessBuilder::new("tag_integration") + .build() + .await?; + + // Create test location with multiple files + let test_location = harness.create_test_location("tag_test").await?; + + test_location + .write_file("docs/file1.txt", "Document 1") + .await?; + test_location + .write_file("docs/file2.txt", "Document 2") + .await?; + test_location + .write_file("images/photo1.jpg", "Photo 1") + .await?; + + let location = test_location + .index("Tag Test Location", IndexMode::Deep) + .await?; + + // Wait for indexing to settle + tokio::time::sleep(Duration::from_millis(500)).await; + + let db = harness.library.db().conn(); + let action_manager = harness.core.context.get_action_manager().await.unwrap(); + let library_id = harness.library.id(); + + // ========================================== + // PART 1: Create tags with event validation + // ========================================== + + // Start collecting events before tag creation + let mut collector = EventCollector::with_capture(&harness.core.events); + let collection_handle = tokio::spawn(async move { + collector.collect_events(Duration::from_secs(3)).await; + collector + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + + // Create first tag + let create_tag1 = + CreateTagAction::from_input(CreateTagInput::simple("Important".to_string())).unwrap(); + let tag1_output = action_manager + .dispatch_library(Some(library_id), create_tag1) + .await?; + let tag1_uuid = tag1_output.tag_id; + + tracing::info!("Created tag 'Important' with UUID: {}", tag1_uuid); + + // Create second tag with namespace + let create_tag2 = CreateTagAction::from_input(CreateTagInput::with_namespace( + "Work".to_string(), + "projects".to_string(), + )) + .unwrap(); + let tag2_output = action_manager + .dispatch_library(Some(library_id), create_tag2) + .await?; + let tag2_uuid = tag2_output.tag_id; + + tracing::info!("Created tag 'Work' with UUID: {}", tag2_uuid); + + // Create third tag + let create_tag3 = + CreateTagAction::from_input(CreateTagInput::simple("Archive".to_string())).unwrap(); + let tag3_output = action_manager + .dispatch_library(Some(library_id), create_tag3) + .await?; + let tag3_uuid = tag3_output.tag_id; + + tracing::info!("Created tag 'Archive' with UUID: {}", tag3_uuid); + + // Wait for events to settle + tokio::time::sleep(Duration::from_millis(500)).await; + + let collector = collection_handle.await?; + let stats = collector.analyze().await; + + tracing::info!("Tag creation events collected:"); + stats.print(); + + // Verify tag creation emitted resource events + let tag_events = stats + .resource_changed + .get("tag") + .or_else(|| stats.resource_changed_batch.get("tag")) + .copied() + .unwrap_or(0); + + assert!( + tag_events >= 3, + "Expected at least 3 tag resource events (one per tag created), got {}", + tag_events + ); + + // ========================================== + // PART 2: Verify tags in database + // ========================================== + + // Verify tag1 exists in database + let tag1_model = tag::Entity::find() + .filter(tag::Column::Uuid.eq(tag1_uuid)) + .one(db) + .await? + .expect("tag1 'Important' should exist in database"); + + assert_eq!(tag1_model.canonical_name, "important"); + assert_eq!(tag1_model.display_name, Some("Important".to_string())); + + // Verify tag2 exists with namespace + let tag2_model = tag::Entity::find() + .filter(tag::Column::Uuid.eq(tag2_uuid)) + .one(db) + .await? + .expect("tag2 'Work' should exist in database"); + + assert_eq!(tag2_model.canonical_name, "work"); + assert_eq!(tag2_model.namespace, Some("projects".to_string())); + + // Verify tag3 exists + let tag3_model = tag::Entity::find() + .filter(tag::Column::Uuid.eq(tag3_uuid)) + .one(db) + .await? + .expect("tag3 'Archive' should exist in database"); + + tracing::info!("All 3 tags verified in database"); + + // ========================================== + // PART 3: Find entries to tag + // ========================================== + + // Find the files we created + let file1 = entry::Entity::find() + .filter(entry::Column::Name.eq("file1.txt")) + .one(db) + .await? + .expect("file1.txt should be indexed"); + + let file2 = entry::Entity::find() + .filter(entry::Column::Name.eq("file2.txt")) + .one(db) + .await? + .expect("file2.txt should be indexed"); + + let photo1 = entry::Entity::find() + .filter(entry::Column::Name.eq("photo1.jpg")) + .one(db) + .await? + .expect("photo1.jpg should be indexed"); + + tracing::info!( + "Found entries: file1={}, file2={}, photo1={}", + file1.id, + file2.id, + photo1.id + ); + + // ========================================== + // PART 4: Apply tags with event validation + // ========================================== + + // Start collecting events for tag application + let mut collector = EventCollector::with_capture(&harness.core.events); + let collection_handle = tokio::spawn(async move { + collector.collect_events(Duration::from_secs(3)).await; + collector + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + + // Apply "Important" tag to file1 and photo1 + let apply1 = ApplyTagsAction::from_input(ApplyTagsInput::user_tags_entry( + vec![file1.id, photo1.id], + vec![tag1_uuid], + )) + .unwrap(); + action_manager + .dispatch_library(Some(library_id), apply1) + .await?; + + tracing::info!("Applied 'Important' tag to file1 and photo1"); + + // Apply "Work" tag to file1 and file2 (file1 gets multiple tags) + let apply2 = ApplyTagsAction::from_input(ApplyTagsInput::user_tags_entry( + vec![file1.id, file2.id], + vec![tag2_uuid], + )) + .unwrap(); + action_manager + .dispatch_library(Some(library_id), apply2) + .await?; + + tracing::info!("Applied 'Work' tag to file1 and file2"); + + // Apply "Archive" tag to photo1 + let apply3 = ApplyTagsAction::from_input(ApplyTagsInput::user_tags_entry( + vec![photo1.id], + vec![tag3_uuid], + )) + .unwrap(); + action_manager + .dispatch_library(Some(library_id), apply3) + .await?; + + tracing::info!("Applied 'Archive' tag to photo1"); + + // Wait for events to settle + tokio::time::sleep(Duration::from_millis(500)).await; + + let collector = collection_handle.await?; + let stats = collector.analyze().await; + + tracing::info!("Tag application events collected:"); + stats.print(); + + // Verify file resource events were emitted when tags were applied + let file_events = stats + .resource_changed + .get("file") + .or_else(|| stats.resource_changed_batch.get("file")) + .copied() + .unwrap_or(0); + + assert!( + file_events >= 3, + "Expected at least 3 file resource events (files were tagged), got {}", + file_events + ); + + // ========================================== + // PART 5: Verify tag applications in database + // ========================================== + + // Verify file1 has 2 tags (Important + Work) + let file1_uuid = file1.uuid.expect("file1 should have UUID"); + let file1_metadata = user_metadata::Entity::find() + .filter(user_metadata::Column::EntryUuid.eq(file1_uuid)) + .one(db) + .await? + .expect("file1 should have user_metadata"); + + let file1_tag_count = user_metadata_tag::Entity::find() + .filter(user_metadata_tag::Column::UserMetadataId.eq(file1_metadata.id)) + .count(db) + .await?; + + assert_eq!( + file1_tag_count, 2, + "file1 should have 2 tags (Important + Work)" + ); + + // Verify file1 has the correct tags + let file1_tags = user_metadata_tag::Entity::find() + .filter(user_metadata_tag::Column::UserMetadataId.eq(file1_metadata.id)) + .all(db) + .await?; + + let file1_tag_ids: Vec = file1_tags.iter().map(|t| t.tag_id).collect(); + assert!( + file1_tag_ids.contains(&tag1_model.id), + "file1 should have 'Important' tag" + ); + assert!( + file1_tag_ids.contains(&tag2_model.id), + "file1 should have 'Work' tag" + ); + + tracing::info!("Verified file1 has 'Important' and 'Work' tags"); + + // Verify file2 has 1 tag (Work) + let file2_uuid = file2.uuid.expect("file2 should have UUID"); + let file2_metadata = user_metadata::Entity::find() + .filter(user_metadata::Column::EntryUuid.eq(file2_uuid)) + .one(db) + .await? + .expect("file2 should have user_metadata"); + + let file2_tag_count = user_metadata_tag::Entity::find() + .filter(user_metadata_tag::Column::UserMetadataId.eq(file2_metadata.id)) + .count(db) + .await?; + + assert_eq!(file2_tag_count, 1, "file2 should have 1 tag (Work)"); + + tracing::info!("Verified file2 has 'Work' tag"); + + // Verify photo1 has 2 tags (Important + Archive) + let photo1_uuid = photo1.uuid.expect("photo1 should have UUID"); + let photo1_metadata = user_metadata::Entity::find() + .filter(user_metadata::Column::EntryUuid.eq(photo1_uuid)) + .one(db) + .await? + .expect("photo1 should have user_metadata"); + + let photo1_tag_count = user_metadata_tag::Entity::find() + .filter(user_metadata_tag::Column::UserMetadataId.eq(photo1_metadata.id)) + .count(db) + .await?; + + assert_eq!( + photo1_tag_count, 2, + "photo1 should have 2 tags (Important + Archive)" + ); + + let photo1_tags = user_metadata_tag::Entity::find() + .filter(user_metadata_tag::Column::UserMetadataId.eq(photo1_metadata.id)) + .all(db) + .await?; + + let photo1_tag_ids: Vec = photo1_tags.iter().map(|t| t.tag_id).collect(); + assert!( + photo1_tag_ids.contains(&tag1_model.id), + "photo1 should have 'Important' tag" + ); + assert!( + photo1_tag_ids.contains(&tag3_model.id), + "photo1 should have 'Archive' tag" + ); + + tracing::info!("Verified photo1 has 'Important' and 'Archive' tags"); + + // ========================================== + // PART 6: Query tags by file (reverse lookup) + // ========================================== + + // Find all tags applied to file1 + let file1_applied_tags = user_metadata_tag::Entity::find() + .filter(user_metadata_tag::Column::UserMetadataId.eq(file1_metadata.id)) + .find_also_related(tag::Entity) + .all(db) + .await?; + + assert_eq!( + file1_applied_tags.len(), + 2, + "file1 should have 2 tag applications" + ); + + let file1_tag_names: Vec = file1_applied_tags + .iter() + .filter_map(|(_, tag_opt)| tag_opt.as_ref()) + .map(|t| t.canonical_name.clone()) + .collect(); + + assert!(file1_tag_names.contains(&"important".to_string())); + assert!(file1_tag_names.contains(&"work".to_string())); + + tracing::info!("Verified file1 tag reverse lookup: {:?}", file1_tag_names); + + // ========================================== + // SUCCESS + // ========================================== + + tracing::info!("✅ All tag integration tests passed!"); + + harness.shutdown().await?; + Ok(()) +}