diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestScreen.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestScreen.kt index 78e90b13c..1a60cf456 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestScreen.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestScreen.kt @@ -29,6 +29,8 @@ import rs.wordpress.example.shared.ui.components.PostCard fun StressTestScreen(viewModel: StressTestViewModel = koinInject()) { val posts by viewModel.posts.collectAsState() val totalUpdates by viewModel.totalUpdates.collectAsState() + val totalInserts by viewModel.totalInserts.collectAsState() + val totalDeletes by viewModel.totalDeletes.collectAsState() val isRunning by viewModel.isRunning.collectAsState() val performanceMetrics by viewModel.performanceMetrics.collectAsState() val listState = rememberLazyListState() @@ -51,7 +53,8 @@ fun StressTestScreen(viewModel: StressTestViewModel = koinInject()) { ) Spacer(modifier = Modifier.height(8.dp)) Text(text = "Total Posts: ${posts.size}") - Text(text = "Total Updates: $totalUpdates") + Text(text = "Updates: $totalUpdates | Inserts: $totalInserts | Deletes: $totalDeletes") + Text(text = "Total Operations: ${totalUpdates + totalInserts + totalDeletes}") Text(text = "Status: ${if (isRunning) "Running" else "Stopped"}") performanceMetrics?.let { metrics -> diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestViewModel.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestViewModel.kt index 069f37c3c..240b9262e 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestViewModel.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/stresstest/StressTestViewModel.kt @@ -39,6 +39,12 @@ class StressTestViewModel( private val _totalUpdates = MutableStateFlow(0L) val totalUpdates: StateFlow = _totalUpdates.asStateFlow() + private val _totalInserts = MutableStateFlow(0L) + val totalInserts: StateFlow = _totalInserts.asStateFlow() + + private val _totalDeletes = MutableStateFlow(0L) + val totalDeletes: StateFlow = _totalDeletes.asStateFlow() + private val _isRunning = MutableStateFlow(false) val isRunning: StateFlow = _isRunning.asStateFlow() @@ -132,9 +138,6 @@ class StressTestViewModel( // Update performance metrics updatePerformanceMetrics(loadDuration, totalLatency) - - // Increment total updates counter - _totalUpdates.value += 1 } } @@ -145,14 +148,32 @@ class StressTestViewModel( // Start comprehensive stress test with: // - 10-100ms delay between batches (variable timing) // - 1-20 posts per batch (variable batch size) + // - 50% updates, 25% deletes, 25% inserts (operation weights) stressTestHandle = mockPostService.startComprehensiveStressTest( entityIds, - minDelayMs = 10u, - maxDelayMs = 100u, - minBatchSize = 1u, - maxBatchSize = 20u + uniffi.wp_mobile.StressTestConfig( + minDelayMs = 10u, + maxDelayMs = 100u, + minBatchSize = 1u, + maxBatchSize = 20u, + updateWeight = 50u, + deleteWeight = 25u, + insertWeight = 25u + ) ) - println("Comprehensive stress test started with ObservableCollection!") + println("Comprehensive stress test started with ObservableCollection (updates/deletes/inserts)!") + + // Poll operation counters from the stress test handle + viewModelScope.launch(Dispatchers.Default) { + while (_isRunning.value) { + stressTestHandle?.let { handle -> + _totalUpdates.value = handle.updateCount().toLong() + _totalInserts.value = handle.insertCount().toLong() + _totalDeletes.value = handle.deleteCount().toLong() + } + kotlinx.coroutines.delay(100) // Poll every 100ms + } + } } private fun updatePerformanceMetrics(loadDuration: Long, totalLatency: Long) { @@ -203,6 +224,9 @@ class StressTestViewModel( } fun onCleared() { + // Stop the running flag to stop polling + _isRunning.value = false + // Stop background updates stressTestHandle?.stop() diff --git a/wp_mobile/src/service/mock_post_service.rs b/wp_mobile/src/service/mock_post_service.rs index 3ff41632c..09ff22cc9 100644 --- a/wp_mobile/src/service/mock_post_service.rs +++ b/wp_mobile/src/service/mock_post_service.rs @@ -19,6 +19,144 @@ use wp_mobile_cache::{ repository::posts::PostRepository, repository::sites::SiteRepository, }; +/// Operation type for stress testing +enum StressTestOperation { + Update, + Delete, + Insert, +} + +/// Configuration for comprehensive stress testing +#[derive(uniffi::Record)] +pub struct StressTestConfig { + /// Minimum delay between batches in milliseconds + pub min_delay_ms: u64, + /// Maximum delay between batches in milliseconds + pub max_delay_ms: u64, + /// Minimum number of posts to operate on per batch + pub min_batch_size: u32, + /// Maximum number of posts to operate on per batch + pub max_batch_size: u32, + /// Relative weight for update operations (e.g., 50 = 50% if all weights sum to 100) + pub update_weight: u32, + /// Relative weight for delete operations + pub delete_weight: u32, + /// Relative weight for insert operations + pub insert_weight: u32, +} + +/// Perform batch update operation for stress testing +fn stress_test_batch_update( + cache: &Arc, + db_site: &DbSite, + repo: &PostRepository, + entity_ids: &[EntityId], + batch_indices: &[usize], + current_count: u64, +) { + batch_indices.iter().for_each(|&idx| { + let entity_id = &entity_ids[idx]; + let _result = cache.execute(|conn| { + if let Some(full_entity) = repo.select_by_entity_id(conn, entity_id)? { + let mut post = full_entity.data.post; + post.title.rendered = + format!("Updated Post {} (batch #{})", post.id.0, current_count); + post.content.rendered = + format!("

Content updated at batch #{}

", current_count); + repo.upsert(conn, db_site, &post)?; + } + Ok::<_, wp_mobile_cache::SqliteDbError>(()) + }); + }); +} + +/// Perform batch delete operation for stress testing +fn stress_test_batch_delete( + cache: &Arc, + repo: &PostRepository, + entity_ids: &[EntityId], + batch_indices: &[usize], +) { + batch_indices.iter().for_each(|&idx| { + let entity_id = &entity_ids[idx]; + let _result = cache.execute(|conn| repo.delete_by_entity_id(conn, entity_id)); + }); +} + +/// Perform batch insert operation for stress testing +fn stress_test_batch_insert( + cache: &Arc, + db_site: &DbSite, + repo: &PostRepository, + batch_size: u32, + next_insert_id: &mut i64, + current_count: u64, +) { + (0..batch_size).for_each(|_| { + let post_id = PostId(*next_insert_id); + *next_insert_id += 1; + + let title = format!("Stress Insert {} (batch #{})", post_id.0, current_count); + let slug = format!("stress-insert-{}", post_id.0); + let link = format!("https://example.com/{}", slug); + let content = format!("

Inserted at batch #{}

", current_count); + let post = create_test_post(post_id, &title, &slug, &link, &content); + + let _result = cache.execute(|conn| repo.upsert(conn, db_site, &post)); + }); +} + +/// Create a temporary post with default values for testing +fn create_test_post( + id: PostId, + title: &str, + slug: &str, + link: &str, + content: &str, +) -> AnyPostWithEditContext { + AnyPostWithEditContext { + id, + date: "2025-01-01T00:00:00".to_string(), + date_gmt: "2025-01-01T00:00:00Z".parse().unwrap(), + guid: PostGuidWithEditContext { + raw: None, + rendered: format!("https://example.com/?p={}", id.0), + }, + link: link.to_string(), + modified: "2025-01-01T00:00:00".to_string(), + modified_gmt: "2025-01-01T00:00:00Z".parse().unwrap(), + slug: slug.to_string(), + status: PostStatus::Publish, + post_type: "post".to_string(), + password: "".to_string(), + permalink_template: None, + generated_slug: None, + title: PostTitleWithEditContext { + raw: None, + rendered: title.to_string(), + }, + content: PostContentWithEditContext { + raw: None, + rendered: content.to_string(), + protected: None, + block_version: None, + }, + author: None, + excerpt: None, + featured_media: None, + comment_status: None, + ping_status: None, + format: None, + meta: None, + sticky: None, + template: "".to_string(), + categories: None, + tags: None, + parent: None, + menu_order: None, + } +} + /// Mock post service for testing purposes /// /// This service provides utilities to insert and update mock posts directly @@ -74,58 +212,14 @@ impl MockPostService { Self { cache, db_site } } - /// Create a temporary post with default values - fn create_temp_post(&self, id: PostId) -> AnyPostWithEditContext { - AnyPostWithEditContext { - id, - date: "2025-01-01T00:00:00".to_string(), - date_gmt: "2025-01-01T00:00:00Z".parse().unwrap(), - guid: PostGuidWithEditContext { - raw: None, - rendered: format!("https://example.com/?p={}", id.0), - }, - link: format!("https://example.com/test-post-{}", id.0), - modified: "2025-01-01T00:00:00".to_string(), - modified_gmt: "2025-01-01T00:00:00Z".parse().unwrap(), - slug: format!("test-post-{}", id.0), - status: PostStatus::Publish, - post_type: "post".to_string(), - password: "".to_string(), - permalink_template: None, - generated_slug: None, - title: PostTitleWithEditContext { - raw: None, - rendered: "Test Post".to_string(), - }, - content: PostContentWithEditContext { - raw: None, - rendered: "

Test content

".to_string(), - protected: None, - block_version: None, - }, - author: None, - excerpt: None, - featured_media: None, - comment_status: None, - ping_status: None, - format: None, - meta: None, - sticky: None, - template: "".to_string(), - categories: None, - tags: None, - parent: None, - menu_order: None, - } - } - /// Insert a mock post for testing purposes /// /// Returns the EntityId of the inserted post, which can be used to create /// observable entities or fetch the post later. pub fn insert_mock_post(&self, id: PostId, title: String) -> EntityId { - let mut post = self.create_temp_post(id); - post.title.rendered = title; + let slug = format!("test-post-{}", id.0); + let link = format!("https://example.com/{}", slug); + let post = create_test_post(id, &title, &slug, &link, "

Test content

"); let repo = PostRepository::::new(); self.cache @@ -166,10 +260,10 @@ impl MockPostService { for i in 0..count { let post_id = PostId(10000 + i as i64); - let mut post = self.create_temp_post(post_id); - post.title.rendered = format!("Stress Test Post {}", i + 1); - post.slug = format!("stress-test-post-{}", i + 1); - post.link = format!("https://example.com/stress-test-post-{}", i + 1); + let title = format!("Stress Test Post {}", i + 1); + let slug = format!("stress-test-post-{}", i + 1); + let link = format!("https://example.com/{}", slug); + let post = create_test_post(post_id, &title, &slug, &link, "

Test content

"); let entity_id = self .cache @@ -248,40 +342,47 @@ impl MockPostService { Arc::new(StressTestHandle { stop_flag, update_counter, + insert_counter: Arc::new(AtomicU64::new(0)), + delete_counter: Arc::new(AtomicU64::new(0)), }) } /// Start a comprehensive stress test with variable batch sizes and timing /// /// This provides a more realistic stress test than `start_random_updates()`: - /// - Updates multiple posts per batch (1-50 posts) - /// - Variable timing (bursts and quiet periods) + /// - Randomly updates, deletes, and inserts posts based on operation weights + /// - Variable batch sizes and timing (bursts and quiet periods) /// - Uses actual random selection instead of round-robin /// /// # Arguments - /// * `entity_ids` - The entity IDs to randomly update - /// * `min_delay_ms` - Minimum delay between batches in milliseconds - /// * `max_delay_ms` - Maximum delay between batches in milliseconds - /// * `min_batch_size` - Minimum number of posts to update per batch - /// * `max_batch_size` - Maximum number of posts to update per batch + /// * `entity_ids` - The entity IDs to randomly update/delete + /// * `config` - Configuration for the stress test behavior pub fn start_comprehensive_stress_test( &self, entity_ids: Vec, - min_delay_ms: u64, - max_delay_ms: u64, - min_batch_size: u32, - max_batch_size: u32, + config: StressTestConfig, ) -> Arc { let stop_flag = Arc::new(Mutex::new(false)); let stop_flag_clone = stop_flag.clone(); let update_counter = Arc::new(AtomicU64::new(0)); let update_counter_clone = update_counter.clone(); + let insert_counter = Arc::new(AtomicU64::new(0)); + let insert_counter_clone = insert_counter.clone(); + let delete_counter = Arc::new(AtomicU64::new(0)); + let delete_counter_clone = delete_counter.clone(); let cache = self.cache.clone(); let db_site = self.db_site; thread::spawn(move || { let repo = PostRepository::::new(); let mut rng = rand::thread_rng(); + let mut next_insert_id: i64 = 20000; // Start IDs for inserted posts + + // Calculate total weight for operation selection + let total_weight = config.update_weight + config.delete_weight + config.insert_weight; + if total_weight == 0 { + return; // No operations to perform + } loop { // Check if we should stop @@ -297,41 +398,57 @@ impl MockPostService { } // Determine batch size for this iteration - let batch_size = rng.gen_range(min_batch_size..=max_batch_size); + let batch_size = rng.gen_range(config.min_batch_size..=config.max_batch_size); let batch_size = batch_size.min(entity_ids.len() as u32); - // Select random posts for this batch - let mut batch_indices = Vec::new(); - for _ in 0..batch_size { - let idx = rng.gen_range(0..entity_ids.len()); - batch_indices.push(idx); - } + // Choose operation based on weights + let roll = rng.gen_range(0..total_weight); + let operation = if roll < config.update_weight { + StressTestOperation::Update + } else if roll < config.update_weight + config.delete_weight { + StressTestOperation::Delete + } else { + StressTestOperation::Insert + }; - // Update all posts in the batch let current_count = update_counter_clone.load(Ordering::Relaxed); - for idx in batch_indices { - let entity_id = &entity_ids[idx]; - - let _result = cache.execute(|conn| { - if let Some(full_entity) = repo.select_by_entity_id(conn, entity_id)? { - let mut post = full_entity.data.post; - post.title.rendered = format!( - "Updated Post {} (batch update #{})", - post.id.0, current_count - ); - post.content.rendered = - format!("

Content updated at batch #{}

", current_count); - repo.upsert(conn, &db_site, &post)?; - } - Ok::<_, wp_mobile_cache::SqliteDbError>(()) - }); - - update_counter_clone.fetch_add(1, Ordering::Relaxed); + // Select random posts for this batch + let batch_indices: Vec = (0..batch_size) + .map(|_| rng.gen_range(0..entity_ids.len())) + .collect(); + + match operation { + StressTestOperation::Update => { + stress_test_batch_update( + &cache, + &db_site, + &repo, + &entity_ids, + &batch_indices, + current_count, + ); + update_counter_clone.fetch_add(batch_size as u64, Ordering::Relaxed); + } + StressTestOperation::Delete => { + stress_test_batch_delete(&cache, &repo, &entity_ids, &batch_indices); + delete_counter_clone.fetch_add(batch_size as u64, Ordering::Relaxed); + } + StressTestOperation::Insert => { + stress_test_batch_insert( + &cache, + &db_site, + &repo, + batch_size, + &mut next_insert_id, + current_count, + ); + insert_counter_clone.fetch_add(batch_size as u64, Ordering::Relaxed); + } } // Variable delay between batches - let delay_ms = rng.gen_range(min_delay_ms..=max_delay_ms); + let delay_ms = rng.gen_range(config.min_delay_ms..=config.max_delay_ms); thread::sleep(Duration::from_millis(delay_ms)); } }); @@ -339,6 +456,8 @@ impl MockPostService { Arc::new(StressTestHandle { stop_flag, update_counter, + insert_counter, + delete_counter, }) } } @@ -346,11 +465,13 @@ impl MockPostService { /// Handle for controlling background stress testing /// /// Allows stopping the background thread that performs random updates -/// and querying the current update count. +/// and querying the current update, insert, and delete counts. #[derive(uniffi::Object)] pub struct StressTestHandle { stop_flag: Arc>, update_counter: Arc, + insert_counter: Arc, + delete_counter: Arc, } #[uniffi::export] @@ -369,4 +490,18 @@ impl StressTestHandle { pub fn update_count(&self) -> u64 { self.update_counter.load(Ordering::Relaxed) } + + /// Get the current number of inserts performed + /// + /// Returns the total count of post inserts since starting. + pub fn insert_count(&self) -> u64 { + self.insert_counter.load(Ordering::Relaxed) + } + + /// Get the current number of deletes performed + /// + /// Returns the total count of post deletes since starting. + pub fn delete_count(&self) -> u64 { + self.delete_counter.load(Ordering::Relaxed) + } } diff --git a/wp_mobile/src/service/posts.rs b/wp_mobile/src/service/posts.rs index 1a17e078e..3c335920e 100644 --- a/wp_mobile/src/service/posts.rs +++ b/wp_mobile/src/service/posts.rs @@ -148,6 +148,52 @@ impl PostService { .execute(|connection| repo.count(connection, &self.db_site)) } + /// Delete a post by its EntityId + /// + /// Returns the number of rows deleted (0 or 1). + /// Automatically deletes associated term relationships. + /// + /// # Arguments + /// * `entity_id` - The EntityId of the post to delete + /// + /// # Returns + /// - `Ok(1)` if the post was deleted + /// - `Ok(0)` if the post doesn't exist + /// - `Err` if there was a database error + pub fn delete_by_entity_id( + &self, + entity_id: &EntityId, + ) -> Result { + let repo = PostRepository::::new(); + self.cache.execute(|connection| { + repo.delete_by_entity_id(connection, entity_id) + .map(|n| n as u64) + }) + } + + /// Delete a post by its WordPress post ID + /// + /// Returns the number of rows deleted (0 or 1). + /// Automatically deletes associated term relationships. + /// + /// # Arguments + /// * `post_id` - The WordPress post ID to delete + /// + /// # Returns + /// - `Ok(1)` if the post was deleted + /// - `Ok(0)` if the post doesn't exist + /// - `Err` if there was a database error + pub fn delete_by_post_id( + &self, + post_id: wp_api::posts::PostId, + ) -> Result { + let repo = PostRepository::::new(); + self.cache.execute(|connection| { + repo.delete_by_post_id(connection, &self.db_site, post_id) + .map(|n| n as u64) + }) + } + /// Create a filtered post collection with edit context /// /// Returns a collection that: @@ -402,6 +448,109 @@ mod tests { pub cache: Arc, } + #[rstest] + fn test_delete_by_entity_id(post_service_ctx: PostServiceTestContext) { + // Setup: Insert test post + let test_post = insert_test_post(&post_service_ctx); + let entity_id = post_service_ctx + .cache + .execute(|conn| { + let repo = PostRepository::::new(); + repo.select_by_post_id(conn, &post_service_ctx.db_site, test_post.id) + .map(|opt| opt.map(|full_entity| *full_entity.entity_id)) + }) + .expect("Database read should succeed") + .expect("Post should exist"); + + // Test: Delete by entity_id + let deleted = post_service_ctx + .post_service + .delete_by_entity_id(&entity_id) + .expect("Delete should succeed"); + + // Assert: Post was deleted + assert_eq!(deleted, 1, "Should delete 1 post"); + + // Verify post no longer exists + let result = post_service_ctx.cache.execute(|conn| { + let repo = PostRepository::::new(); + repo.select_by_entity_id(conn, &entity_id) + }); + assert!( + result.unwrap().is_none(), + "Post should not exist after deletion" + ); + } + + #[rstest] + fn test_delete_by_post_id(post_service_ctx: PostServiceTestContext) { + // Setup: Insert test post + let test_post = insert_test_post(&post_service_ctx); + + // Test: Delete by post_id + let deleted = post_service_ctx + .post_service + .delete_by_post_id(test_post.id) + .expect("Delete should succeed"); + + // Assert: Post was deleted + assert_eq!(deleted, 1, "Should delete 1 post"); + + // Verify post no longer exists + let result = post_service_ctx.cache.execute(|conn| { + let repo = PostRepository::::new(); + repo.select_by_post_id(conn, &post_service_ctx.db_site, test_post.id) + }); + assert!( + result.unwrap().is_none(), + "Post should not exist after deletion" + ); + } + + #[rstest] + fn test_delete_by_entity_id_non_existent_returns_zero( + post_service_ctx: PostServiceTestContext, + ) { + // Setup: Insert a post and get its entity_id + let test_post = insert_test_post(&post_service_ctx); + let entity_id = post_service_ctx + .cache + .execute(|conn| { + let repo = PostRepository::::new(); + repo.select_by_post_id(conn, &post_service_ctx.db_site, test_post.id) + .map(|opt| opt.map(|full_entity| *full_entity.entity_id)) + }) + .expect("Database read should succeed") + .expect("Post should exist"); + + // Setup: Delete the post via service + post_service_ctx + .post_service + .delete_by_entity_id(&entity_id) + .expect("First delete should succeed"); + + // Test: Try to delete again with the same entity_id (now non-existent) + let deleted = post_service_ctx + .post_service + .delete_by_entity_id(&entity_id) + .expect("Delete should not error"); + + // Assert: Should return 0 + assert_eq!(deleted, 0, "Should return 0 for non-existent post"); + } + + #[rstest] + fn test_delete_by_post_id_non_existent_returns_zero(post_service_ctx: PostServiceTestContext) { + // Test: Delete non-existent post + let deleted = post_service_ctx + .post_service + .delete_by_post_id(PostId(99999)) + .expect("Delete should not error"); + + // Assert: Should return 0 + assert_eq!(deleted, 0, "Should return 0 for non-existent post"); + } + /// rstest fixture providing a PostService with in-memory database /// /// Sets up an in-memory SQLite database with migrations, creates a test site, diff --git a/wp_mobile_cache/src/repository/posts.rs b/wp_mobile_cache/src/repository/posts.rs index 1c3b3feef..910162403 100644 --- a/wp_mobile_cache/src/repository/posts.rs +++ b/wp_mobile_cache/src/repository/posts.rs @@ -307,6 +307,40 @@ impl PostRepository { })) } + /// Delete a post by its EntityId for a given site. + /// + /// Returns the number of rows deleted (0 or 1). + /// Automatically deletes associated term relationships. + /// + /// Returns an error if the EntityId's table name doesn't match this repository's context. + /// Returns `Ok(0)` if no post with the given EntityId exists. + pub fn delete_by_entity_id( + &self, + executor: &impl QueryExecutor, + entity_id: &EntityId, + ) -> Result { + // Validate that the entity_id is for the correct table + entity_id.validate_table(C::table())?; + + // Get the WordPress post ID from the rowid (lightweight SELECT) + let sql = format!( + "SELECT id FROM {} WHERE db_site_id = ? AND rowid = ?", + Self::table_name() + ); + let mut stmt = executor.prepare(&sql)?; + let post_id = stmt + .query_row([entity_id.db_site.row_id, entity_id.rowid], |row| { + row.get::<_, i64>(0) + }) + .optional() + .map_err(SqliteDbError::from)?; + + match post_id { + Some(id) => self.delete_by_post_id(executor, &entity_id.db_site, PostId(id)), + None => Ok(0), // Post doesn't exist + } + } + /// Delete a post by its WordPress post ID for a given site. /// /// Returns the number of rows deleted (0 or 1). @@ -317,12 +351,6 @@ impl PostRepository { site: &DbSite, post_id: PostId, ) -> Result { - // First, try to get the rowid (if post doesn't exist, return 0) - let _db_post = match self.select_by_post_id(executor, site, post_id)? { - Some(post) => post, - None => return Ok(0), // Post doesn't exist - }; - // Delete term relationships using WordPress post ID let term_repo = TermRelationshipRepository; term_repo.delete_all_terms_for_object(executor, site, post_id.0)?; @@ -1410,6 +1438,69 @@ mod tests { assert_eq!(deleted, 0); } + #[rstest] + fn test_repository_delete_by_entity_id(mut test_ctx: TestContext) { + let post = PostBuilder::minimal().with_id(42).build(); + let entity_id = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Verify exists + test_ctx + .post_repo + .select_by_entity_id(&test_ctx.conn, &entity_id) + .expect("Should not error") + .expect("Post should exist"); + + // Delete + let deleted = test_ctx + .post_repo + .delete_by_entity_id(&test_ctx.conn, &entity_id) + .unwrap(); + assert_eq!(deleted, 1); + + // Verify no longer exists + let result = test_ctx + .post_repo + .select_by_entity_id(&test_ctx.conn, &entity_id) + .unwrap(); + assert!(result.is_none(), "Post should not exist after deletion"); + } + + #[rstest] + fn test_delete_by_entity_id_deletes_terms(mut test_ctx: TestContext) { + // Insert post with terms + let post = PostBuilder::minimal() + .with_id(500) + .with_categories(vec![wp_api::terms::TermId(1), wp_api::terms::TermId(2)]) + .build(); + let entity_id = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Verify terms exist + let terms = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &test_ctx.site, post.id.0) + .unwrap(); + assert!(!terms.is_empty()); + + // Delete post by entity_id + test_ctx + .post_repo + .delete_by_entity_id(&test_ctx.conn, &entity_id) + .unwrap(); + + // Verify terms were also deleted + let terms_after = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &test_ctx.site, post.id.0) + .unwrap(); + assert!(terms_after.is_empty()); + } + #[rstest] fn test_repository_upsert_inserts_new_post(mut test_ctx: TestContext) { let post = PostBuilder::minimal() diff --git a/wp_mobile_cache/src/repository/posts_constraint_tests.rs b/wp_mobile_cache/src/repository/posts_constraint_tests.rs index a7bbd2ec6..023a9bbbe 100644 --- a/wp_mobile_cache/src/repository/posts_constraint_tests.rs +++ b/wp_mobile_cache/src/repository/posts_constraint_tests.rs @@ -129,6 +129,22 @@ fn test_delete_non_existent_post_returns_zero(test_ctx: TestContext) { ); } +#[rstest] +fn test_delete_by_entity_id_non_existent_returns_zero(test_ctx: TestContext) { + // Create an EntityId with a non-existent rowid + let non_existent_entity_id = EntityId::new(test_ctx.site, EditContext::table(), RowId(99999)); + + let deleted = test_ctx + .post_repo + .delete_by_entity_id(&test_ctx.conn, &non_existent_entity_id) + .unwrap(); + + assert_eq!( + deleted, 0, + "Should return 0 when deleting non-existent entity" + ); +} + #[rstest] fn test_count_returns_zero_for_empty_site(test_ctx: TestContext) { let count = test_ctx