"""End-to-end backup roundtrip: seed -> export -> wipe -> import -> verify. Drives the backup service module directly (no HTTP layer) against a fresh SQLite DB built in the conftest temp data dir. Verifies entity counts and key fields survive a full round-trip. Kept under 5s by avoiding the lifespan startup — we build a private engine in an isolated DB file so we don't share state with other tests in the session. """ from __future__ import annotations from pathlib import Path import pytest from sqlalchemy.ext.asyncio import create_async_engine from sqlmodel import SQLModel, select from sqlmodel.ext.asyncio.session import AsyncSession @pytest.fixture async def isolated_engine(tmp_path: Path): """A throwaway SQLite engine + freshly created schema for one test. Avoids the global engine in ``database.engine`` — tests in the same session share that singleton, and recreating tables on it would corrupt parallel tests' state. """ # Importing the module registers all SQLModel tables on the metadata. from notify_bridge_server.database import models # noqa: F401 db_path = tmp_path / "roundtrip.db" engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}") async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) yield engine await engine.dispose() async def _seed(session: AsyncSession, user_id: int) -> dict[str, int]: """Insert enough rows to exercise the major code paths in import/export.""" from notify_bridge_server.database.models import ( EventLog, NotificationTarget, NotificationTracker, ServiceProvider, TargetReceiver, TelegramBot, TrackingConfig, User, ) user = User( id=user_id, username="roundtrip-user", hashed_password="hash", role="user", ) session.add(user) await session.flush() bot = TelegramBot( user_id=user_id, name="Test bot", token="123456:fake-token-value", bot_username="testbot", bot_id=1, ) session.add(bot) await session.flush() provider = ServiceProvider( user_id=user_id, type="immich", name="Immich prod", config={"base_url": "https://immich.example.com", "api_key": "secret"}, ) session.add(provider) await session.flush() target = NotificationTarget( user_id=user_id, type="telegram", name="My channel", config={"bot_token_id": bot.id, "disable_url_preview": True}, ) session.add(target) await session.flush() receiver = TargetReceiver( target_id=target.id, name="Channel A", config={"chat_id": "-100123"}, receiver_key="-100123", locale="en", ) session.add(receiver) tc = TrackingConfig( user_id=user_id, provider_type="immich", name="Default Immich tracking", track_assets_added=True, ) session.add(tc) await session.flush() tracker = NotificationTracker( user_id=user_id, provider_id=provider.id, name="Family album tracker", scan_interval=120, collection_ids=["album-uuid-1"], ) session.add(tracker) await session.flush() # Capture IDs before commit — accessing attributes after commit # triggers a refresh that needs an async-IO context the test caller # may not be inside. Better to snapshot now and use plain ints later. ids = { "provider_id": provider.id, "target_id": target.id, "bot_id": bot.id, "tracker_id": tracker.id, "tracking_config_id": tc.id, "tracker_name": tracker.name, "provider_name": provider.name, } # EventLog rows are NOT in the backup schema — they're operational data, # not configuration. Insert a few anyway so we can verify they survive # the export step (since export only reads, never writes/wipes them). for i in range(3): session.add(EventLog( user_id=user_id, tracker_id=ids["tracker_id"], tracker_name=ids["tracker_name"], provider_id=ids["provider_id"], provider_name=ids["provider_name"], event_type="assets_added", collection_id="album-uuid-1", collection_name="Family", assets_count=i, )) await session.commit() return ids async def _wipe_user_owned_rows(engine, user_id: int) -> None: """Delete every backup-able row for the user via raw SQL. Using ORM-level deletes triggers SQLAlchemy's cascade machinery, which lazy-loads relationships in a sync context that the async driver cannot serve (MissingGreenlet). Raw DELETE statements skip cascades and let SQLite's FKs enforce ordering naturally. Order matters: child rows first, then parents. """ from sqlalchemy import text statements = ( "DELETE FROM event_log", "DELETE FROM notification_tracker_target", "DELETE FROM notification_tracker", "DELETE FROM target_receiver", "DELETE FROM notification_target", "DELETE FROM tracking_config", "DELETE FROM service_provider", "DELETE FROM template_slot", "DELETE FROM template_config", "DELETE FROM telegram_bot", "DELETE FROM appsetting", ) async with engine.begin() as conn: for stmt in statements: try: await conn.execute(text(stmt)) except Exception: # noqa: BLE001 — table may not exist in test schema pass @pytest.mark.asyncio async def test_export_wipe_import_roundtrip(isolated_engine, tmp_data_dir) -> None: # noqa: ARG001 """A full round-trip preserves entity counts and the key fields the UI relies on — names, configs (with secrets included), provider references via id_map. """ from notify_bridge_server.database.models import ( NotificationTarget, NotificationTracker, ServiceProvider, TargetReceiver, TelegramBot, TrackingConfig, ) from notify_bridge_server.services.backup_schema import ( ConflictMode, SecretsMode, ) from notify_bridge_server.services.backup_service import ( export_backup, import_backup, ) user_id = 1 # ---- Seed ---- async with AsyncSession(isolated_engine) as session: ids = await _seed(session, user_id) # ---- Export with secrets included so import sees real values ---- async with AsyncSession(isolated_engine) as session: backup = await export_backup( session, user_id, secrets_mode=SecretsMode.INCLUDE, ) assert len(backup.data.providers) == 1 assert len(backup.data.telegram_bots) == 1 assert len(backup.data.targets) == 1 assert len(backup.data.targets[0].receivers) == 1 assert len(backup.data.tracking_configs) == 1 assert len(backup.data.notification_trackers) == 1 assert backup.data.providers[0].config["api_key"] == "secret" # ---- Wipe ---- await _wipe_user_owned_rows(isolated_engine, user_id) async with AsyncSession(isolated_engine) as session: result = await session.exec( select(ServiceProvider).where(ServiceProvider.user_id == user_id) ) assert result.all() == [] # ---- Import ---- async with AsyncSession(isolated_engine) as session: result = await import_backup( session, user_id, backup, conflict_mode=ConflictMode.SKIP, ) assert result.errors == [], f"Import errors: {result.errors}" assert result.created > 0 # ---- Verify the entities are back ---- async with AsyncSession(isolated_engine) as session: providers = (await session.exec( select(ServiceProvider).where(ServiceProvider.user_id == user_id) )).all() assert len(providers) == 1 prov = providers[0] assert prov.name == "Immich prod" assert prov.config["base_url"] == "https://immich.example.com" # Secrets imported intact when SecretsMode.INCLUDE was used at export. assert prov.config["api_key"] == "secret" bots = (await session.exec( select(TelegramBot).where(TelegramBot.user_id == user_id) )).all() assert len(bots) == 1 assert bots[0].name == "Test bot" targets = (await session.exec( select(NotificationTarget).where(NotificationTarget.user_id == user_id) )).all() assert len(targets) == 1 receivers = (await session.exec( select(TargetReceiver).where(TargetReceiver.target_id == targets[0].id) )).all() assert len(receivers) == 1 assert receivers[0].config["chat_id"] == "-100123" tcs = (await session.exec( select(TrackingConfig).where(TrackingConfig.user_id == user_id) )).all() assert len(tcs) == 1 assert tcs[0].name == "Default Immich tracking" trackers = (await session.exec( select(NotificationTracker).where(NotificationTracker.user_id == user_id) )).all() assert len(trackers) == 1 # provider_id was remapped via id_map — original provider id may have # changed across the wipe, so just check it links to a real row. assert trackers[0].provider_id == prov.id assert trackers[0].scan_interval == 120 assert trackers[0].collection_ids == ["album-uuid-1"]