admin frontend elkezdése, járművek tisztázása, frontend fejleszts

This commit is contained in:
Roo
2026-06-15 18:52:38 +00:00
parent ef8df9608c
commit 213ba3b0f1
80 changed files with 11615 additions and 2304 deletions

View File

@@ -0,0 +1,31 @@
"""Check and create the CheckConstraint for vehicle.assets table."""
from app.database import engine_sync
from sqlalchemy import text
with engine_sync.connect() as conn:
# Check if constraint exists
result = conn.execute(
text("""
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'vehicle.assets'::regclass
AND contype = 'c'
""")
).fetchall()
print('Constraints on vehicle.assets:')
for row in result:
print(f' {row[0]}: {row[1]}')
if not result:
print('No CheckConstraint found - creating it now...')
conn.execute(
text("""
ALTER TABLE vehicle.assets
ADD CONSTRAINT ck_asset_vin_or_plate_required
CHECK (vin IS NOT NULL OR license_plate IS NOT NULL)
""")
)
conn.commit()
print('CheckConstraint created successfully!')
else:
print('CheckConstraint already exists.')

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""
Repair script for user gy.krisztina76@gmail.com (ID 100).
This user went through light registration + email verification but the KYC
completion failed because of two bugs:
1. verify_email() used Person.user_id (NULL) instead of User.person_id
2. GamificationService.award_points() did internal commit conflicting with outer transaction
This script manually creates all the missing infrastructure that should have
been created by complete_kyc():
- Activate Person
- Create Organization (personal)
- Create Branch
- Create OrganizationMember
- Create Wallet
- Create UserStats
- Set user.scope_id
"""
import asyncio
import logging
import sys
from datetime import datetime, timezone
# Add the backend directory to the path
sys.path.insert(0, '/app/backend')
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.config import settings
# Setup logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger("repair_kyc")
# Database URL from settings
DATABASE_URL = settings.DATABASE_URL
logger.info(f"Using database: {DATABASE_URL}")
# Create engine and session
engine = create_async_engine(DATABASE_URL, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def repair_user():
async with AsyncSessionLocal() as db:
try:
# 1. Find the user
from app.models.identity.identity import User, Person, Wallet
from app.models.marketplace.organization import Organization, OrganizationMember, Branch
from app.models.gamification.gamification import UserStats, PointsLedger
stmt = select(User).where(User.email == 'gy.krisztina76@gmail.com')
user = (await db.execute(stmt)).scalar_one_or_none()
if not user:
logger.error("User not found!")
return
logger.info(f"Found user: ID={user.id}, email={user.email}, person_id={user.person_id}")
logger.info(f" is_active={user.is_active}, scope_id={user.scope_id}")
# 2. Find and activate Person
person_stmt = select(Person).where(Person.id == user.person_id)
person = (await db.execute(person_stmt)).scalar_one_or_none()
if person:
logger.info(f"Found Person: ID={person.id}, is_active={person.is_active}, user_id={person.user_id}")
person.is_active = True
person.user_id = user.id
logger.info(f" -> Activated Person, set user_id={user.id}")
else:
logger.warning(f"Person with id={user.person_id} not found!")
# 3. Check if Organization already exists
org_stmt = select(Organization).where(Organization.owner_id == user.id)
existing_org = (await db.execute(org_stmt)).scalar_one_or_none()
if existing_org:
logger.info(f"Organization already exists: ID={existing_org.id}, name={existing_org.name}")
new_org = existing_org
else:
# Create personal Organization
from app.core.security import generate_secure_slug
org_name = f"{person.last_name} Flotta" if person else "Personal Flotta"
folder_slug = generate_secure_slug(12)
new_org = Organization(
name=org_name,
full_name=org_name,
display_name=org_name,
folder_slug=folder_slug,
org_type="individual",
status="ACTIVE",
is_deleted=False,
is_active=True,
is_verified=False,
is_anonymized=False,
default_currency="HUF",
country_code="HU",
language="hu",
subscription_plan="FREE",
base_asset_limit=3,
purchased_extra_slots=0,
notification_settings={},
external_integration_config={},
visual_settings={},
is_ownership_transferable=False,
owner_id=user.id,
first_registered_at=datetime.now(timezone.utc),
current_lifecycle_started_at=datetime.now(timezone.utc),
lifecycle_index=1,
created_at=datetime.now(timezone.utc)
)
db.add(new_org)
await db.flush()
logger.info(f"Created Organization: ID={new_org.id}, name={org_name}")
# 4. Check if Branch already exists
branch_stmt = select(Branch).where(Branch.organization_id == new_org.id)
existing_branch = (await db.execute(branch_stmt)).scalar_one_or_none()
if existing_branch:
logger.info(f"Branch already exists: ID={existing_branch.id}, name={existing_branch.name}")
else:
import uuid
branch = Branch(
id=uuid.uuid4(),
organization_id=new_org.id,
name="Home Base",
is_main=True,
opening_hours={},
branch_rating=0.0,
status="ACTIVE",
is_deleted=False,
created_at=datetime.now(timezone.utc)
)
db.add(branch)
await db.flush()
logger.info(f"Created Branch: ID={branch.id}")
# 5. Check if OrganizationMember already exists
member_stmt = select(OrganizationMember).where(
and_(
OrganizationMember.organization_id == new_org.id,
OrganizationMember.user_id == user.id
)
)
existing_member = (await db.execute(member_stmt)).scalar_one_or_none()
if existing_member:
logger.info(f"OrganizationMember already exists: ID={existing_member.id}")
else:
member = OrganizationMember(
organization_id=new_org.id,
user_id=user.id,
role="OWNER",
permissions={},
is_permanent=True,
is_verified=True
)
db.add(member)
await db.flush()
logger.info(f"Created OrganizationMember: user_id={user.id}, org_id={new_org.id}")
# 6. Check if Wallet already exists
wallet_stmt = select(Wallet).where(Wallet.user_id == user.id)
existing_wallet = (await db.execute(wallet_stmt)).scalar_one_or_none()
if existing_wallet:
logger.info(f"Wallet already exists: ID={existing_wallet.id}")
else:
wallet = Wallet(
user_id=user.id,
earned_credits=0,
purchased_credits=0,
service_coins=0,
currency="HUF"
)
db.add(wallet)
await db.flush()
logger.info(f"Created Wallet for user_id={user.id}")
# 7. Check if UserStats already exists
stats_stmt = select(UserStats).where(UserStats.user_id == user.id)
existing_stats = (await db.execute(stats_stmt)).scalar_one_or_none()
if existing_stats:
logger.info(f"UserStats already exists: user_id={existing_stats.user_id}")
else:
stats = UserStats(
user_id=user.id,
total_xp=500, # KYC bonus
social_points=0,
current_level=1,
penalty_points=0,
restriction_level=0,
updated_at=datetime.now(timezone.utc),
penalty_quota_remaining=0,
places_discovered=0,
places_validated=0
)
db.add(stats)
# Add PointsLedger entry for KYC bonus
ledger = PointsLedger(
user_id=user.id,
points=500,
reason="KYC_VERIFICATION_REPAIR"
)
db.add(ledger)
await db.flush()
logger.info(f"Created UserStats and PointsLedger for user_id={user.id}")
# 8. Set user.scope_id if not set
if not user.scope_id:
user.scope_id = str(new_org.id)
logger.info(f"Set user.scope_id = {new_org.id}")
# 9. Ensure user.is_active is True
if not user.is_active:
user.is_active = True
logger.info("Activated user")
await db.commit()
logger.info("=" * 50)
logger.info("REPAIR COMPLETED SUCCESSFULLY!")
logger.info("=" * 50)
# Verify
logger.info("\n--- Verification ---")
logger.info(f"User: ID={user.id}, is_active={user.is_active}, scope_id={user.scope_id}")
if person:
logger.info(f"Person: ID={person.id}, is_active={person.is_active}, user_id={person.user_id}")
logger.info(f"Organization: ID={new_org.id}, name={new_org.name}")
except Exception as e:
await db.rollback()
logger.error(f"Repair failed: {e}")
import traceback
traceback.print_exc()
finally:
await db.close()
await engine.dispose()
if __name__ == "__main__":
asyncio.run(repair_user())

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
Seed script: CostCategory min_tier beállítása.
A szkript beállítja a költségkategóriák min_tier mezőjét a kód alapján.
A tier-ek hierarchikusak: free < premium < enterprise.
Kategória tier hozzárendelés:
- FUEL, SERVICE, TIRES, INSURANCE, TAXES -> free
- TOLL_PARKING, CLEANING, OTHER -> premium
- (minden más) -> enterprise
Használat:
docker compose exec sf_api python -m backend.scripts.seed_cost_category_tiers
"""
import asyncio
import logging
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select, text
from app.core.config import settings
from app.models.vehicle.vehicle import CostCategory
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Tier mapping by category code
# A fő kategóriák (parent_id IS NULL) tier beállításai.
# Az alkategóriák (pl. FUEL_PETROL, MAINT_SERVICE) öröklik a szülő tier-jét.
CATEGORY_TIER_MAP = {
"FUEL": "free", # Üzemanyag / Töltés
"MAINTENANCE": "free", # Szerviz & Karbantartás
"TIRES": "free", # Gumiabroncsok
"INSURANCE": "free", # Biztosítás
"TAX": "free", # Adók
"FEES": "premium", # Útdíj & Parkolás
"ADMIN": "premium", # Hatósági díjak
"FINANCE": "premium", # Finanszírozás
"CLEANING": "premium", # Ápolás & Kozmetika
"OTHER": "premium", # Egyéb
}
# Default tier for categories not in the map
DEFAULT_TIER = "enterprise"
async def seed_cost_category_tiers():
"""Beállítja a min_tier értékeket az összes CostCategory rekordra."""
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
try:
# Lekérjük az összes kategóriát
stmt = select(CostCategory).order_by(CostCategory.id)
result = await db.execute(stmt)
categories = result.scalars().all()
if not categories:
logger.warning("❌ Nincsenek CostCategory rekordok az adatbázisban!")
return
updated_count = 0
for cat in categories:
# Meghatározzuk a tier-t a kód alapján
new_tier = CATEGORY_TIER_MAP.get(cat.code, DEFAULT_TIER)
# Csak akkor frissítjük, ha még nincs beállítva, vagy változott
if cat.min_tier != new_tier:
cat.min_tier = new_tier
updated_count += 1
logger.info(
f" ✅ [{cat.code:15s}] {cat.name:30s} -> tier={new_tier}"
)
if updated_count > 0:
await db.commit()
logger.info(f"\n{updated_count} kategória tier beállítva.")
else:
logger.info("\n✅ Minden kategória tier már be van állítva.")
# Összesítő
logger.info("\n📊 Tier összesítő:")
for cat in categories:
logger.info(f" [{cat.min_tier:10s}] {cat.code:15s} - {cat.name}")
except Exception as e:
await db.rollback()
logger.error(f"❌ Hiba a seedelés során: {e}")
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed_cost_category_tiers())

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
E2E Test: Light Registration → Email Verification → KYC Completion
Verifies that all necessary database relationships are created after KYC.
"""
import asyncio
import httpx
import uuid
import sys
from datetime import datetime, timezone
sys.path.insert(0, '/app')
from sqlalchemy import text
from app.db.session import AsyncSessionLocal
BASE_URL = "http://localhost:8000/api/v1"
TEST_EMAIL = f"test_kyc_{uuid.uuid4().hex[:8]}@example.com"
TEST_PASSWORD = "TestPass123!"
async def get_verification_token(email: str):
async with AsyncSessionLocal() as session:
result = await session.execute(
text("SELECT token FROM identity.verification_tokens vt "
"JOIN identity.users u ON u.id = vt.user_id "
"WHERE u.email = :email ORDER BY vt.created_at DESC LIMIT 1"),
{"email": email}
)
return result.scalar()
async def check_infrastructure(user_id: int):
"""Check all required infrastructure objects exist."""
async with AsyncSessionLocal() as session:
checks = {}
# Person
r = await session.execute(
text("SELECT id, is_active, user_id FROM identity.persons WHERE id = "
"(SELECT person_id FROM identity.users WHERE id = :uid)"),
{"uid": user_id}
)
person = r.fetchone()
checks["person"] = person is not None
if person:
checks["person_active"] = person[1] == True
checks["person_user_id"] = person[2] == user_id
# Organization
r = await session.execute(
text("SELECT id, name, org_type FROM fleet.organizations WHERE owner_id = :uid"),
{"uid": user_id}
)
org = r.fetchone()
checks["organization"] = org is not None
if org:
checks["org_id"] = org[0]
# Branch
if org:
r = await session.execute(
text("SELECT id, name, is_main FROM fleet.branches WHERE organization_id = :oid"),
{"oid": org[0]}
)
branch = r.fetchone()
checks["branch"] = branch is not None
if branch:
checks["branch_main"] = branch[2] == True
# OrganizationMember
r = await session.execute(
text("SELECT id, role FROM fleet.organization_members WHERE user_id = :uid"),
{"uid": user_id}
)
member = r.fetchone()
checks["org_member"] = member is not None
if member:
checks["org_member_role"] = member[1] == "OWNER"
# Wallet
r = await session.execute(
text("SELECT id FROM identity.wallets WHERE user_id = :uid"),
{"uid": user_id}
)
checks["wallet"] = r.fetchone() is not None
# UserStats
r = await session.execute(
text("SELECT user_id, total_xp, current_level FROM gamification.user_stats WHERE user_id = :uid"),
{"uid": user_id}
)
stats = r.fetchone()
checks["user_stats"] = stats is not None
if stats:
checks["user_stats_xp"] = stats[1] >= 500 # KYC bonus
checks["user_stats_level"] = stats[2] >= 1
# User scope_id
r = await session.execute(
text("SELECT scope_id FROM identity.users WHERE id = :uid"),
{"uid": user_id}
)
scope = r.fetchone()
checks["scope_id_set"] = scope is not None and scope[0] is not None
return checks
async def run_test():
passed = 0
failed = 0
print("=" * 60)
print(f"KYC E2E TEST - Email: {TEST_EMAIL}")
print("=" * 60)
async with httpx.AsyncClient() as client:
# --- STEP 1: LITE REGISTRATION ---
print("\n--- 1. LITE REGISTRATION ---")
reg_payload = {
"email": TEST_EMAIL,
"password": TEST_PASSWORD,
"first_name": "KYC",
"last_name": "TestUser",
"region_code": "HU"
}
resp = await client.post(f"{BASE_URL}/auth/register", json=reg_payload)
print(f"Status: {resp.status_code}")
if resp.status_code == 201:
print("✓ Registration successful")
passed += 1
else:
print(f"✗ Registration failed: {resp.text}")
failed += 1
return passed, failed
# --- STEP 2: EMAIL VERIFICATION ---
print("\n--- 2. EMAIL VERIFICATION ---")
token = await get_verification_token(TEST_EMAIL)
if not token:
print("✗ No verification token found!")
failed += 1
return passed, failed
print(f"Token: {token}")
verify_resp = await client.post(f"{BASE_URL}/auth/verify-email", json={"token": str(token)})
print(f"Status: {verify_resp.status_code}")
if verify_resp.status_code == 200:
print("✓ Email verification successful")
passed += 1
else:
print(f"✗ Verification failed: {verify_resp.text}")
failed += 1
return passed, failed
# --- STEP 3: LOGIN TO GET TOKEN ---
print("\n--- 3. LOGIN ---")
login_data = {
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"grant_type": "password"
}
login_resp = await client.post(f"{BASE_URL}/auth/login", data=login_data)
print(f"Status: {login_resp.status_code}")
if login_resp.status_code == 200:
print("✓ Login successful")
passed += 1
access_token = login_resp.json().get("access_token")
else:
print(f"✗ Login failed: {login_resp.text}")
failed += 1
return passed, failed
headers = {"Authorization": f"Bearer {access_token}"}
# --- STEP 4: GET USER INFO ---
print("\n--- 4. GET USER INFO ---")
me_resp = await client.get(f"{BASE_URL}/auth/me", headers=headers)
print(f"Status: {me_resp.status_code}")
if me_resp.status_code == 200:
user_data = me_resp.json()
user_id = user_data.get("id")
print(f"User ID: {user_id}")
print(f"User data: {user_data}")
passed += 1
else:
print(f"✗ Failed to get user info: {me_resp.text}")
failed += 1
return passed, failed
# --- STEP 5: COMPLETE KYC ---
print("\n--- 5. COMPLETE KYC ---")
kyc_payload = {
"first_name": "KYC",
"last_name": "TestUser",
"birth_date": "1990-01-15",
"birth_place": "Budapest",
"mothers_last_name": "Anyuka",
"mothers_first_name": "Mária",
"phone_number": "+36301234567",
"address_zip": "1011",
"address_city": "Budapest",
"address_street_name": "Fő utca",
"address_street_type": "utca",
"address_house_number": "1",
"preferred_currency": "HUF",
"region_code": "HU",
"identity_docs": {
"PASSPORT": {"number": "AB123456", "expiry_date": "2030-01-01"}
}
}
kyc_resp = await client.post(f"{BASE_URL}/auth/complete-kyc", json=kyc_payload, headers=headers)
print(f"Status: {kyc_resp.status_code}")
print(f"Response: {kyc_resp.text}")
if kyc_resp.status_code == 200:
print("✓ KYC completion successful")
passed += 1
else:
print(f"✗ KYC failed: {kyc_resp.text}")
failed += 1
return passed, failed
# --- STEP 6: VERIFY INFRASTRUCTURE ---
print("\n--- 6. VERIFY INFRASTRUCTURE ---")
checks = await check_infrastructure(user_id)
all_ok = True
for check_name, check_result in checks.items():
status = "" if check_result else ""
if not check_result:
all_ok = False
print(f" {status} {check_name}: {check_result}")
if all_ok:
print("✓ All infrastructure checks passed!")
passed += 1
else:
print(f"✗ Some infrastructure checks failed!")
failed += 1
print("\n" + "=" * 60)
print(f"RESULTS: {passed} passed, {failed} failed")
print("=" * 60)
return passed, failed
if __name__ == "__main__":
p, f = asyncio.run(run_test())
sys.exit(1 if f > 0 else 0)

View File

@@ -0,0 +1,269 @@
"""
Test script for:
1. Asset VIN/License Plate OR-OR validation (Pydantic schemas)
2. Person-Preserving Soft Delete logic
"""
import asyncio
import sys
from datetime import datetime, timezone
from typing import Optional
# ─── Test 1: Pydantic Schema Validation ───────────────────────────────────────
print("=" * 70)
print("🧪 TEST 1: Asset Pydantic OR-OR Validation")
print("=" * 70)
from pydantic import ValidationError
sys.path.insert(0, "/app")
from app.schemas.asset import AssetCreate, AssetUpdate
def test_asset_create():
"""Test AssetCreate schema validation."""
passed = 0
failed = 0
# Test 1.1: Both vin and plate provided - should pass
try:
a = AssetCreate(license_plate="ABC-123", vin="WDB1234567890ABCD")
assert a.license_plate == "ABC-123"
assert a.vin == "WDB1234567890ABCD"
print(" ✅ 1.1 Both vin and plate provided: PASS")
passed += 1
except Exception as e:
print(f" ❌ 1.1 Both vin and plate provided: FAIL - {e}")
failed += 1
# Test 1.2: Only license_plate - should pass
try:
a = AssetCreate(license_plate="ABC-123")
assert a.license_plate == "ABC-123"
assert a.vin is None
print(" ✅ 1.2 Only license_plate: PASS")
passed += 1
except Exception as e:
print(f" ❌ 1.2 Only license_plate: FAIL - {e}")
failed += 1
# Test 1.3: Only vin - should pass
try:
a = AssetCreate(vin="WDB1234567890ABCD")
assert a.vin == "WDB1234567890ABCD"
assert a.license_plate is None
print(" ✅ 1.3 Only vin: PASS")
passed += 1
except Exception as e:
print(f" ❌ 1.3 Only vin: FAIL - {e}")
failed += 1
# Test 1.4: Neither vin nor plate - should FAIL
try:
a = AssetCreate()
print(f" ❌ 1.4 Neither vin nor plate: FAIL (should have raised ValueError)")
failed += 1
except ValidationError as e:
print(f" ✅ 1.4 Neither vin nor plate: PASS (correctly rejected)")
passed += 1
except Exception as e:
print(f" ❌ 1.4 Neither vin nor plate: FAIL - {e}")
failed += 1
# Test 1.5: Empty string license_plate - should be converted to None, then fail
try:
a = AssetCreate(license_plate="")
print(f" ❌ 1.5 Empty string license_plate: FAIL (should have raised ValueError)")
failed += 1
except ValidationError as e:
print(f" ✅ 1.5 Empty string license_plate: PASS (correctly rejected)")
passed += 1
except Exception as e:
print(f" ❌ 1.5 Empty string license_plate: FAIL - {e}")
failed += 1
# Test 1.6: Empty string vin - should be converted to None, then fail
try:
a = AssetCreate(vin=" ")
print(f" ❌ 1.6 Whitespace-only vin: FAIL (should have raised ValueError)")
failed += 1
except ValidationError as e:
print(f" ✅ 1.6 Whitespace-only vin: PASS (correctly rejected)")
passed += 1
except Exception as e:
print(f" ❌ 1.6 Whitespace-only vin: FAIL - {e}")
failed += 1
# Test 1.7: Both empty strings - should fail
try:
a = AssetCreate(license_plate="", vin="")
print(f" ❌ 1.7 Both empty strings: FAIL (should have raised ValueError)")
failed += 1
except ValidationError as e:
print(f" ✅ 1.7 Both empty strings: PASS (correctly rejected)")
passed += 1
except Exception as e:
print(f" ❌ 1.7 Both empty strings: FAIL - {e}")
failed += 1
return passed, failed
def test_asset_update():
"""Test AssetUpdate schema validation."""
passed = 0
failed = 0
# Test 2.1: Update with only license_plate - should pass
try:
a = AssetUpdate(license_plate="NEW-999")
assert a.license_plate == "NEW-999"
print(" ✅ 2.1 Update with only license_plate: PASS")
passed += 1
except Exception as e:
print(f" ❌ 2.1 Update with only license_plate: FAIL - {e}")
failed += 1
# Test 2.2: Update with only vin - should pass
try:
a = AssetUpdate(vin="NEWVIN123456789")
assert a.vin == "NEWVIN123456789"
print(" ✅ 2.2 Update with only vin: PASS")
passed += 1
except Exception as e:
print(f" ❌ 2.2 Update with only vin: FAIL - {e}")
failed += 1
# Test 2.3: Update with both set to None explicitly - should FAIL
try:
a = AssetUpdate(license_plate=None, vin=None)
print(f" ❌ 2.3 Both set to None: FAIL (should have raised ValueError)")
failed += 1
except ValidationError as e:
print(f" ✅ 2.3 Both set to None: PASS (correctly rejected)")
passed += 1
except Exception as e:
print(f" ❌ 2.3 Both set to None: FAIL - {e}")
failed += 1
# Test 2.4: Empty update (no fields) - should pass (exclude_unset)
try:
a = AssetUpdate()
print(f" ✅ 2.4 Empty update (no fields): PASS")
passed += 1
except Exception as e:
print(f" ❌ 2.4 Empty update (no fields): FAIL - {e}")
failed += 1
return passed, failed
# Run Pydantic tests
p1, f1 = test_asset_create()
p2, f2 = test_asset_update()
print(f"\n📊 Asset Validation Results: {p1+p2} passed, {f1+f2} failed out of {p1+p2+f1+f2} tests")
# ─── Test 2: Soft Delete Logic ────────────────────────────────────────────────
print("\n" + "=" * 70)
print("🧪 TEST 2: Person-Preserving Soft Delete Logic")
print("=" * 70)
async def test_soft_delete():
"""Test the soft_delete_user method via direct DB calls."""
from app.database import AsyncSessionLocal
from app.models.identity.identity import User, Person
from app.services.auth_service import AuthService
from app.services.security_service import security_service
from sqlalchemy import select, text
async with AsyncSessionLocal() as db:
try:
# Find a test user (use first active user)
result = await db.execute(
select(User).where(User.is_deleted == False).limit(1)
)
user = result.scalar_one_or_none()
if not user:
print(" ⚠️ No active test user found - creating one...")
# Create a minimal test scenario
print(" ⚠️ Skipping DB soft delete test (no test user available)")
return 0, 0
user_id = user.id
old_email = user.email
print(f" 📋 Test user: id={user_id}, email={old_email}")
# Check if Person exists
person = None
if user.person_id:
result = await db.execute(
select(Person).where(Person.id == user.person_id)
)
person = result.scalar_one_or_none()
if person:
print(f" 📋 Person record: id={person.id}, is_active={person.is_active}")
# Perform soft delete
success = await AuthService.soft_delete_user(
db=db,
user_id=user_id,
reason="test_script_validation",
actor_id=user_id
)
if not success:
print(f" ❌ Soft delete returned False (user already deleted)")
return 0, 1
# Verify user state
await db.refresh(user)
assert user.is_deleted == True, "is_deleted should be True"
assert user.is_active == False, "is_active should be False"
assert user.deleted_at is not None, "deleted_at should be set"
assert "deleted_" in user.email, f"Email should be anonymized: {user.email}"
assert str(user_id) in user.email, "Email should contain user_id"
print(f" ✅ User.is_deleted = {user.is_deleted}")
print(f" ✅ User.is_active = {user.is_active}")
print(f" ✅ User.deleted_at = {user.deleted_at}")
print(f" ✅ User.email anonymized: {user.email}")
# Verify Person is UNTOUCHED by our soft delete
if person:
await db.refresh(person)
# Person.deleted_at must remain None (our code never sets it)
assert person.deleted_at is None, "Person.deleted_at should remain None (not touched by soft delete)"
# Person data must be unchanged (no email rewrite, no name change)
print(f" ✅ Person.deleted_at preserved = {person.deleted_at} (not touched)")
print(f" ✅ Person data preserved (no modifications by soft delete)")
else:
print(f" ⚠️ No Person record linked to this user")
# Restore user for other tests
user.is_deleted = False
user.is_active = True
user.deleted_at = None
user.email = old_email
await db.commit()
print(f" ✅ User restored to original state")
return 2, 0
except Exception as e:
await db.rollback()
print(f" ❌ Soft delete test failed: {e}")
import traceback
traceback.print_exc()
return 0, 1
p3, f3 = asyncio.run(test_soft_delete())
# ─── Final Summary ────────────────────────────────────────────────────────────
print("\n" + "=" * 70)
total_p = p1 + p2 + p3
total_f = f1 + f2 + f3
print(f"📊 FINAL RESULTS: {total_p} passed, {total_f} failed out of {total_p+total_f} tests")
print("=" * 70)
if total_f > 0:
print("❌ SOME TESTS FAILED!")
sys.exit(1)
else:
print("✅ ALL TESTS PASSED!")
sys.exit(0)