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:
2026-03-28 21:08:07 +01:00
parent 332960de30
commit 9bceeaac9c
26 changed files with 1069 additions and 51 deletions

View 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