feat(arch): implement soft delete for business-critical models
Adds SoftDeleteMixin (deleted_at + deleted_by_id) with automatic query
filtering via do_orm_execute event. Soft-deleted records are invisible
by default; bypass with execution_options={"include_deleted": True}.
Models: User, Merchant, Store, StoreUser, Customer, Order, Product,
LoyaltyProgram, LoyaltyCard.
Infrastructure:
- SoftDeleteMixin in models/database/base.py
- Auto query filter registered on SessionLocal and test sessions
- soft_delete(), restore(), soft_delete_cascade() in app/core/soft_delete.py
- Alembic migration adding columns to 9 tables
- Partial unique indexes on users.email/username, stores.store_code/subdomain
Service changes:
- admin_service: delete_user, delete_store → soft_delete/soft_delete_cascade
- merchant_service: delete_merchant → soft_delete_cascade (stores→children)
- store_team_service: remove_team_member → soft_delete (fixes is_active bug)
- product_service: delete_product → soft_delete
- program_service: delete_program → soft_delete_cascade
Admin API:
- include_deleted/only_deleted query params on admin list endpoints
- PUT restore endpoints for users, merchants, stores
Tests: 9 unit tests for soft-delete infrastructure.
Docs: docs/backend/soft-delete.md + follow-up proposals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
296
tests/unit/core/test_soft_delete.py
Normal file
296
tests/unit/core/test_soft_delete.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# tests/unit/core/test_soft_delete.py
|
||||
"""
|
||||
Unit tests for soft-delete infrastructure.
|
||||
|
||||
Tests the SoftDeleteMixin, automatic query filtering, and utility functions.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.soft_delete import restore, soft_delete, soft_delete_cascade
|
||||
from app.modules.tenancy.models import Merchant, Store, StoreUser, User
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_owner(db):
|
||||
"""Create a user for soft-delete tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"sdowner_{uid}@test.com",
|
||||
username=f"sdowner_{uid}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_merchant(db, sd_owner):
|
||||
"""Create a merchant for soft-delete tests."""
|
||||
merchant = Merchant(
|
||||
name="SD Test Merchant",
|
||||
owner_user_id=sd_owner.id,
|
||||
contact_email=sd_owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_store(db, sd_merchant):
|
||||
"""Create a store for soft-delete tests."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
store = Store(
|
||||
merchant_id=sd_merchant.id,
|
||||
store_code=f"SDTEST_{uid.upper()}",
|
||||
subdomain=f"sdtest{uid}",
|
||||
name=f"SD Test Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_member_user(db):
|
||||
"""Create another user to be a store member."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"sdmember_{uid}@test.com",
|
||||
username=f"sdmember_{uid}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_store_user(db, sd_store, sd_member_user):
|
||||
"""Create a StoreUser membership."""
|
||||
store_user = StoreUser(
|
||||
store_id=sd_store.id,
|
||||
user_id=sd_member_user.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
db.refresh(store_user)
|
||||
return store_user
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SoftDeleteMixin basic behavior
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSoftDeleteBasic:
|
||||
"""Test soft_delete() utility function."""
|
||||
|
||||
def test_soft_delete_sets_fields(self, db, sd_owner, sd_store):
|
||||
"""soft_delete() sets deleted_at and deleted_by_id."""
|
||||
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
# Query with include_deleted to see the record
|
||||
store = (
|
||||
db.query(Store)
|
||||
.execution_options(include_deleted=True)
|
||||
.filter(Store.id == sd_store.id)
|
||||
.first()
|
||||
)
|
||||
assert store is not None
|
||||
assert store.deleted_at is not None
|
||||
assert store.deleted_by_id == sd_owner.id
|
||||
|
||||
def test_soft_deleted_excluded_from_queries(self, db, sd_owner, sd_store):
|
||||
"""Soft-deleted records are automatically excluded from normal queries."""
|
||||
store_id = sd_store.id
|
||||
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
result = db.query(Store).filter(Store.id == store_id).first()
|
||||
assert result is None
|
||||
|
||||
def test_soft_deleted_visible_with_include_deleted(self, db, sd_owner, sd_store):
|
||||
"""Soft-deleted records are visible with include_deleted=True."""
|
||||
store_id = sd_store.id
|
||||
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
result = (
|
||||
db.query(Store)
|
||||
.execution_options(include_deleted=True)
|
||||
.filter(Store.id == store_id)
|
||||
.first()
|
||||
)
|
||||
assert result is not None
|
||||
assert result.id == store_id
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Restore
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRestore:
|
||||
"""Test restore() utility function."""
|
||||
|
||||
def test_restore_clears_deleted_fields(self, db, sd_owner, sd_store):
|
||||
"""restore() clears deleted_at and deleted_by_id."""
|
||||
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
restored = restore(db, Store, sd_store.id, restored_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
assert restored.deleted_at is None
|
||||
assert restored.deleted_by_id is None
|
||||
|
||||
def test_restore_makes_record_visible(self, db, sd_owner, sd_store):
|
||||
"""After restore, record is visible in normal queries."""
|
||||
store_id = sd_store.id
|
||||
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
restore(db, Store, store_id, restored_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
result = db.query(Store).filter(Store.id == store_id).first()
|
||||
assert result is not None
|
||||
|
||||
def test_restore_not_deleted_raises(self, db, sd_store):
|
||||
"""restore() raises ValueError if record is not deleted."""
|
||||
with pytest.raises(ValueError, match="is not deleted"):
|
||||
restore(db, Store, sd_store.id, restored_by_id=1)
|
||||
|
||||
def test_restore_not_found_raises(self, db):
|
||||
"""restore() raises ValueError if record doesn't exist."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
restore(db, Store, 99999, restored_by_id=1)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cascade soft delete
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSoftDeleteCascade:
|
||||
"""Test soft_delete_cascade() utility function."""
|
||||
|
||||
def test_cascade_deletes_parent_and_children(
|
||||
self, db, sd_owner, sd_store, sd_store_user
|
||||
):
|
||||
"""soft_delete_cascade() deletes parent and its children."""
|
||||
count = soft_delete_cascade(
|
||||
db,
|
||||
sd_store,
|
||||
deleted_by_id=sd_owner.id,
|
||||
cascade_rels=[("store_users", [])],
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert count == 2 # store + store_user
|
||||
|
||||
# Both should be hidden from normal queries
|
||||
assert db.query(Store).filter(Store.id == sd_store.id).first() is None
|
||||
assert (
|
||||
db.query(StoreUser).filter(StoreUser.id == sd_store_user.id).first()
|
||||
is None
|
||||
)
|
||||
|
||||
# Both visible with include_deleted
|
||||
store = (
|
||||
db.query(Store)
|
||||
.execution_options(include_deleted=True)
|
||||
.filter(Store.id == sd_store.id)
|
||||
.first()
|
||||
)
|
||||
assert store is not None
|
||||
assert store.deleted_at is not None
|
||||
|
||||
su = (
|
||||
db.query(StoreUser)
|
||||
.execution_options(include_deleted=True)
|
||||
.filter(StoreUser.id == sd_store_user.id)
|
||||
.first()
|
||||
)
|
||||
assert su is not None
|
||||
assert su.deleted_at is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Partial unique indexes
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPartialUniqueIndexes:
|
||||
"""Test that unique constraints allow reuse after soft delete."""
|
||||
|
||||
def test_user_email_reusable_after_soft_delete(self, db):
|
||||
"""Soft-deleted user's email can be used by a new user."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
email = f"reuse_{uuid.uuid4().hex[:8]}@test.com"
|
||||
username = f"reuse_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
user1 = User(
|
||||
email=email,
|
||||
username=username,
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user1)
|
||||
db.commit()
|
||||
|
||||
# Soft-delete user1
|
||||
soft_delete(db, user1, deleted_by_id=None)
|
||||
db.commit()
|
||||
|
||||
# Create user2 with same email — should succeed
|
||||
user2 = User(
|
||||
email=email,
|
||||
username=f"reuse2_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user2)
|
||||
db.commit()
|
||||
db.refresh(user2)
|
||||
|
||||
assert user2.id is not None
|
||||
assert user2.email == email
|
||||
Reference in New Issue
Block a user