"""Tests for SoftDeleteMixin.""" from datetime import datetime import pytest from sqlalchemy import select from abacus_models.core.contexts import set_user_context from abacus_models.core.errors import ERROR_ENTITY_IS_SOFT_DELETED from abacus_models.core.utils import current_timestamp from tests.conftest import ( TestModelDeletedAtOnly, TestModelDeletedByOnly, TestModelFull, ) class TestSoftDelete: """Tests for soft_delete() method.""" def test_soft_delete_sets_deleted_at( self, session, user_context, sample_full_model ): """Test soft_delete() sets deleted_at field.""" before = current_timestamp() sample_full_model.soft_delete() after = current_timestamp() assert sample_full_model.deleted_at is not None assert ( before <= sample_full_model.deleted_at <= after or sample_full_model.deleted_at == before ) def test_soft_delete_sets_deleted_by( self, session, user_context, sample_full_model ): """Test soft_delete() sets deleted_by field.""" sample_full_model.soft_delete() assert sample_full_model.deleted_by == 'test_user_123' def test_soft_delete_sets_both_fields( self, session, user_context, sample_full_model ): """Test soft_delete() sets both deleted_at and deleted_by.""" sample_full_model.soft_delete() assert sample_full_model.deleted_at is not None assert sample_full_model.deleted_by == 'test_user_123' def test_soft_delete_already_deleted_raises( self, session, user_context, sample_full_model ): """Test soft_delete() raises error if already deleted.""" sample_full_model.soft_delete() with pytest.raises(ValueError, match=ERROR_ENTITY_IS_SOFT_DELETED): sample_full_model.soft_delete() def test_soft_delete_with_different_users(self, session): """Test soft_delete() uses current user context.""" with set_user_context('user_1'): model = TestModelFull(name='Test') session.add(model) session.flush() with set_user_context('user_2'): model.soft_delete() assert model.deleted_by == 'user_2' def test_soft_delete_deleted_at_only_model(self, session, user_context): """Test soft_delete() works with model having only deleted_at.""" model = TestModelDeletedAtOnly(name='Test') session.add(model) session.flush() model.soft_delete() assert model.deleted_at is not None assert not hasattr(model, 'deleted_by') def test_soft_delete_deleted_by_only_model(self, session, user_context): """Test soft_delete() works with model having only deleted_by.""" model = TestModelDeletedByOnly(name='Test') session.add(model) session.flush() model.soft_delete() assert model.deleted_by == 'test_user_123' assert not hasattr(model, 'deleted_at') class TestRestore: """Tests for restore() method.""" def test_restore_clears_deleted_at(self, session, user_context, sample_full_model): """Test restore() clears deleted_at field.""" sample_full_model.soft_delete() assert sample_full_model.deleted_at is not None sample_full_model.restore() assert sample_full_model.deleted_at is None def test_restore_clears_deleted_by(self, session, user_context, sample_full_model): """Test restore() clears deleted_by field.""" sample_full_model.soft_delete() assert sample_full_model.deleted_by is not None sample_full_model.restore() assert sample_full_model.deleted_by is None def test_restore_clears_both_fields(self, session, user_context, sample_full_model): """Test restore() clears both deleted_at and deleted_by.""" sample_full_model.soft_delete() sample_full_model.restore() assert sample_full_model.deleted_at is None assert sample_full_model.deleted_by is None def test_restore_already_active_succeeds( self, session, user_context, sample_full_model ): """Test restore() on non-deleted model succeeds.""" sample_full_model.restore() # Should not raise assert sample_full_model.deleted_at is None assert sample_full_model.deleted_by is None def test_restore_then_delete_again(self, session, user_context, sample_full_model): """Test model can be deleted again after restore.""" sample_full_model.soft_delete() sample_full_model.restore() sample_full_model.soft_delete() # Should not raise assert sample_full_model.deleted_at is not None def test_restore_deleted_at_only_model(self, session, user_context): """Test restore() works with model having only deleted_at.""" model = TestModelDeletedAtOnly(name='Test') session.add(model) session.flush() model.soft_delete() model.restore() assert model.deleted_at is None def test_restore_deleted_by_only_model(self, session, user_context): """Test restore() works with model having only deleted_by.""" model = TestModelDeletedByOnly(name='Test') session.add(model) session.flush() model.soft_delete() model.restore() assert model.deleted_by is None class TestIsDeleted: """Tests for is_deleted() method.""" def test_is_deleted_returns_false_for_active( self, session, user_context, sample_full_model ): """Test is_deleted() returns False for active model.""" assert sample_full_model.is_deleted() is False def test_is_deleted_returns_true_after_delete( self, session, user_context, sample_full_model ): """Test is_deleted() returns True after soft_delete().""" sample_full_model.soft_delete() assert sample_full_model.is_deleted() is True def test_is_deleted_returns_false_after_restore( self, session, user_context, sample_full_model ): """Test is_deleted() returns False after restore().""" sample_full_model.soft_delete() sample_full_model.restore() assert sample_full_model.is_deleted() is False def test_is_deleted_checks_deleted_at_first(self, session, user_context): """Test is_deleted() prioritizes deleted_at over deleted_by.""" model = TestModelFull(name='Test') session.add(model) session.flush() # Manually set deleted_by without deleted_at model.deleted_by = 'user_123' # Since deleted_at exists and is None, should return False assert model.is_deleted() is False # Now set deleted_at model.deleted_at = datetime.now() assert model.is_deleted() is True def test_is_deleted_deleted_at_only_model(self, session, user_context): """Test is_deleted() with model having only deleted_at.""" model = TestModelDeletedAtOnly(name='Test') session.add(model) session.flush() assert model.is_deleted() is False model.soft_delete() assert model.is_deleted() is True def test_is_deleted_deleted_by_only_model(self, session, user_context): """Test is_deleted() with model having only deleted_by.""" model = TestModelDeletedByOnly(name='Test') session.add(model) session.flush() assert model.is_deleted() is False model.soft_delete() assert model.is_deleted() is True class TestFilterActive: """Tests for filter_active() classmethod.""" def test_filter_active_returns_only_active(self, session, user_context): """Test filter_active() returns only non-deleted records.""" model1 = TestModelFull(name='Active') model2 = TestModelFull(name='Deleted') session.add_all([model1, model2]) session.flush() model2.soft_delete() session.flush() stmt = select(TestModelFull) stmt = TestModelFull.filter_active(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 1 assert results[0].name == 'Active' def test_filter_active_multiple_deleted(self, session, user_context): """Test filter_active() with multiple deleted records.""" active = TestModelFull(name='Active') deleted1 = TestModelFull(name='Deleted 1') deleted2 = TestModelFull(name='Deleted 2') session.add_all([active, deleted1, deleted2]) session.flush() deleted1.soft_delete() deleted2.soft_delete() session.flush() stmt = select(TestModelFull) stmt = TestModelFull.filter_active(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 1 assert results[0].name == 'Active' def test_filter_active_all_active(self, session, user_context): """Test filter_active() when all records are active.""" model1 = TestModelFull(name='Active 1') model2 = TestModelFull(name='Active 2') session.add_all([model1, model2]) session.flush() stmt = select(TestModelFull) stmt = TestModelFull.filter_active(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 2 def test_filter_active_all_deleted(self, session, user_context): """Test filter_active() when all records are deleted.""" model1 = TestModelFull(name='Deleted 1') model2 = TestModelFull(name='Deleted 2') session.add_all([model1, model2]) session.flush() model1.soft_delete() model2.soft_delete() session.flush() stmt = select(TestModelFull) stmt = TestModelFull.filter_active(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 0 def test_filter_active_deleted_at_only_model(self, session, user_context): """Test filter_active() with model having only deleted_at.""" model1 = TestModelDeletedAtOnly(name='Active') model2 = TestModelDeletedAtOnly(name='Deleted') session.add_all([model1, model2]) session.flush() model2.soft_delete() session.flush() stmt = select(TestModelDeletedAtOnly) stmt = TestModelDeletedAtOnly.filter_active(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 1 assert results[0].name == 'Active' class TestFilterDeleted: """Tests for filter_deleted() classmethod.""" def test_filter_deleted_returns_only_deleted(self, session, user_context): """Test filter_deleted() returns only deleted records.""" model1 = TestModelFull(name='Active') model2 = TestModelFull(name='Deleted') session.add_all([model1, model2]) session.flush() model2.soft_delete() session.flush() stmt = select(TestModelFull) stmt = TestModelFull.filter_deleted(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 1 assert results[0].name == 'Deleted' def test_filter_deleted_multiple_deleted(self, session, user_context): """Test filter_deleted() with multiple deleted records.""" active = TestModelFull(name='Active') deleted1 = TestModelFull(name='Deleted 1') deleted2 = TestModelFull(name='Deleted 2') session.add_all([active, deleted1, deleted2]) session.flush() deleted1.soft_delete() deleted2.soft_delete() session.flush() stmt = select(TestModelFull) stmt = TestModelFull.filter_deleted(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 2 names = {r.name for r in results} assert names == {'Deleted 1', 'Deleted 2'} class TestSoftDeleteMany: """Tests for soft_delete_many() classmethod.""" def test_soft_delete_many_deletes_multiple(self, session, user_context): """Test soft_delete_many() deletes multiple records.""" model1 = TestModelFull(name='Test 1') model2 = TestModelFull(name='Test 2') model3 = TestModelFull(name='Test 3') session.add_all([model1, model2, model3]) session.commit() TestModelFull.soft_delete_many(session, [model1.id, model2.id]) session.commit() session.refresh(model1) session.refresh(model2) session.refresh(model3) assert model1.is_deleted() is True assert model2.is_deleted() is True assert model3.is_deleted() is False def test_soft_delete_many_sets_fields(self, session, user_context): """Test soft_delete_many() sets deleted_at and deleted_by.""" model1 = TestModelFull(name='Test 1') model2 = TestModelFull(name='Test 2') session.add_all([model1, model2]) session.commit() before = current_timestamp() TestModelFull.soft_delete_many(session, [model1.id, model2.id]) session.commit() after = current_timestamp() session.refresh(model1) session.refresh(model2) assert model1.deleted_at is not None assert before <= model1.deleted_at <= after or model1.deleted_at == before assert model1.deleted_by == 'test_user_123' assert model2.deleted_at is not None assert before <= model2.deleted_at <= after or model2.deleted_at == before assert model2.deleted_by == 'test_user_123' def test_soft_delete_many_empty_list(self, session, user_context): """Test soft_delete_many() with empty list does nothing.""" model = TestModelFull(name='Test') session.add(model) session.commit() TestModelFull.soft_delete_many(session, []) session.commit() session.refresh(model) assert model.is_deleted() is False def test_soft_delete_many_nonexistent_ids(self, session, user_context): """Test soft_delete_many() with non-existent IDs doesn't error.""" model = TestModelFull(name='Test') session.add(model) session.commit() # Try to delete non-existent IDs TestModelFull.soft_delete_many(session, [99999, 88888]) session.commit() session.refresh(model) assert model.is_deleted() is False class TestSoftDeleteIntegration: """Integration tests combining soft delete with other mixins.""" def test_soft_delete_updates_last_modified(self, session, user_context): """Test soft_delete() triggers UpdateMixin (via event).""" model = TestModelFull(name='Test') session.add(model) session.flush() original_modified = model.last_modified model.soft_delete() session.flush() session.refresh(model) # last_modified should be updated by UpdateMixin event assert model.last_modified != original_modified def test_restore_updates_last_modified(self, session, user_context): """Test restore() triggers UpdateMixin (via event).""" model = TestModelFull(name='Test') session.add(model) session.flush() model.soft_delete() session.flush() time_after_delete = model.last_modified model.restore() session.flush() session.refresh(model) # last_modified should be updated again assert model.last_modified != time_after_delete class TestQueryChaining: """Tests for query chaining with filter methods.""" def test_filter_active_with_where_clause(self, session, user_context): """Test filter_active() can be chained with WHERE predicates.""" model1 = TestModelFull(name='Apple') model2 = TestModelFull(name='Banana') model3 = TestModelFull(name='Apricot') session.add_all([model1, model2, model3]) session.flush() model3.soft_delete() session.flush() # Chain filter_active with a WHERE clause for names starting with 'App' stmt = select(TestModelFull).where(TestModelFull.name.like('App%')) stmt = TestModelFull.filter_active(stmt) results = session.execute(stmt).scalars().all() # Should return only 'Apple' (not 'Apricot' which is deleted) assert len(results) == 1 assert results[0].name == 'Apple' def test_filter_deleted_with_where_clause(self, session, user_context): """Test filter_deleted() can be chained with WHERE predicates.""" model1 = TestModelFull(name='Deleted A') model2 = TestModelFull(name='Deleted B') model3 = TestModelFull(name='Active A') session.add_all([model1, model2, model3]) session.flush() model1.soft_delete() model2.soft_delete() session.flush() # Chain filter_deleted with a WHERE clause stmt = select(TestModelFull).where(TestModelFull.name.like('%B%')) stmt = TestModelFull.filter_deleted(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 1 assert results[0].name == 'Deleted B' def test_filter_active_with_order_by(self, session, user_context): """Test filter_active() with ORDER BY clause.""" model1 = TestModelFull(name='Charlie') model2 = TestModelFull(name='Alice') model3 = TestModelFull(name='Bob') model4 = TestModelFull(name='Deleted') session.add_all([model1, model2, model3, model4]) session.flush() model4.soft_delete() session.flush() stmt = select(TestModelFull).order_by(TestModelFull.name) stmt = TestModelFull.filter_active(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 3 assert [r.name for r in results] == ['Alice', 'Bob', 'Charlie'] def test_filter_active_with_limit(self, session, user_context): """Test filter_active() with LIMIT clause.""" models = [TestModelFull(name=f'Model {i}') for i in range(5)] session.add_all(models) session.flush() models[4].soft_delete() session.flush() stmt = select(TestModelFull).limit(2) stmt = TestModelFull.filter_active(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 2 assert all(not r.is_deleted() for r in results) def test_filter_active_with_offset(self, session, user_context): """Test filter_active() with OFFSET clause.""" models = [TestModelFull(name=f'Model {i}') for i in range(5)] session.add_all(models) session.flush() models[0].soft_delete() session.flush() stmt = select(TestModelFull).order_by(TestModelFull.id).offset(1) stmt = TestModelFull.filter_active(stmt) results = session.execute(stmt).scalars().all() # Should skip first active record and return the rest (excluding deleted) assert len(results) == 3 def test_multiple_filter_active_calls(self, session, user_context): """Test that calling filter_active() multiple times is idempotent.""" model1 = TestModelFull(name='Active') model2 = TestModelFull(name='Deleted') session.add_all([model1, model2]) session.flush() model2.soft_delete() session.flush() stmt = select(TestModelFull) stmt = TestModelFull.filter_active(stmt) stmt = TestModelFull.filter_active(stmt) # Call twice results = session.execute(stmt).scalars().all() assert len(results) == 1 assert results[0].name == 'Active' def test_filter_active_then_filter_deleted_returns_empty( self, session, user_context ): """Test that chaining filter_active and filter_deleted returns no results.""" model1 = TestModelFull(name='Active') model2 = TestModelFull(name='Deleted') session.add_all([model1, model2]) session.flush() model2.soft_delete() session.flush() # This should return nothing (active AND deleted is impossible) stmt = select(TestModelFull) stmt = TestModelFull.filter_active(stmt) stmt = TestModelFull.filter_deleted(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 0 class TestSoftDeleteEdgeCases: """Tests for edge cases and boundary conditions.""" def test_soft_delete_raises_if_manually_set_deleted_at(self, session, user_context): """Test that soft_delete() raises error if deleted_at already set.""" model = TestModelFull(name='Test') session.add(model) session.flush() # Manually set deleted_at to a specific time manual_time = datetime(2020, 1, 1, 12, 0, 0) model.deleted_at = manual_time session.flush() # Call soft_delete - should raise because already deleted with pytest.raises(ValueError, match=ERROR_ENTITY_IS_SOFT_DELETED): model.soft_delete() def test_restore_idempotency(self, session, user_context): """Test that calling restore() multiple times is safe (idempotent).""" model = TestModelFull(name='Test') session.add(model) session.flush() model.soft_delete() session.flush() assert model.is_deleted() is True model.restore() session.flush() assert model.is_deleted() is False # Call restore again on already active record model.restore() session.flush() assert model.is_deleted() is False def test_soft_delete_not_idempotent(self, session, user_context): """Test that calling soft_delete() multiple times raises error.""" model = TestModelFull(name='Test') session.add(model) session.flush() model.soft_delete() session.flush() assert model.is_deleted() is True # Call soft_delete again - should raise with pytest.raises(ValueError, match=ERROR_ENTITY_IS_SOFT_DELETED): model.soft_delete() def test_soft_delete_with_already_deleted_model(self, session, user_context): """Test soft_delete() on a model that already has deleted_at set.""" model = TestModelFull(name='Test') model.deleted_at = datetime(2020, 1, 1, 12, 0, 0) model.deleted_by = 'old_user' session.add(model) session.flush() assert model.is_deleted() is True # Soft delete again with new user - should raise with pytest.raises(ValueError, match=ERROR_ENTITY_IS_SOFT_DELETED): model.soft_delete() def test_restore_with_null_deleted_at(self, session, user_context): """Test restore() on a model that was never deleted.""" model = TestModelFull(name='Test') session.add(model) session.flush() assert model.is_deleted() is False # Restore a never-deleted model - should be no-op model.restore() session.flush() assert model.is_deleted() is False assert model.deleted_at is None assert model.deleted_by is None def test_filter_active_with_no_records(self, session, user_context): """Test filter_active() when no records exist.""" stmt = select(TestModelFull) stmt = TestModelFull.filter_active(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 0 def test_filter_active_with_all_deleted(self, session, user_context): """Test filter_active() when all records are deleted.""" model1 = TestModelFull(name='Test 1') model2 = TestModelFull(name='Test 2') session.add_all([model1, model2]) session.flush() model1.soft_delete() model2.soft_delete() session.flush() stmt = select(TestModelFull) stmt = TestModelFull.filter_active(stmt) results = session.execute(stmt).scalars().all() assert len(results) == 0 def test_soft_delete_many_with_empty_list(self, session, user_context): """Test soft_delete_many() with an empty list of IDs.""" model = TestModelFull(name='Test') session.add(model) session.commit() # Call with empty list - should be no-op TestModelFull.soft_delete_many(session, []) session.commit() session.refresh(model) assert model.is_deleted() is False def test_soft_delete_many_with_nonexistent_ids(self, session, user_context): """Test soft_delete_many() with IDs that don't exist.""" model = TestModelFull(name='Test') session.add(model) session.commit() # Try to delete non-existent IDs TestModelFull.soft_delete_many(session, [99999, 88888]) session.commit() session.refresh(model) # Existing model should not be affected assert model.is_deleted() is False def test_soft_delete_many_partial_match(self, session, user_context): """Test soft_delete_many() with mix of existing and non-existing IDs.""" model1 = TestModelFull(name='Test 1') model2 = TestModelFull(name='Test 2') session.add_all([model1, model2]) session.commit() # Mix of real and fake IDs TestModelFull.soft_delete_many(session, [model1.id, 99999]) session.commit() session.refresh(model1) session.refresh(model2) # Only model1 should be deleted assert model1.is_deleted() is True assert model2.is_deleted() is False