"""SP17 — BackupScheduler unit tests. Exercises the asyncio tick / start / stop loop without spinning up the FastAPI app. The scheduler wraps a real BackupService against a real on-disk sqlite DB. """ from __future__ import annotations import asyncio from datetime import datetime, timedelta, timezone from pathlib import Path import pytest from cyclone import backup_service as svc_mod from cyclone import backup_scheduler as sched_mod from cyclone import db @pytest.fixture def fresh_db(tmp_path, monkeypatch): monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db") db._reset_for_tests() db.init_db() from cyclone.db import Batch import uuid with db.SessionLocal()() as s: s.add(Batch( id=str(uuid.uuid4()), kind="837P", input_filename="seed.x12", parsed_at=datetime.now(timezone.utc), totals_json=None, validation_json=None, raw_result_json={"envelope": {"control_number": "1"}, "claims": [], "summary": {"passed": 0, "failed": 0, "failed_claim_ids": []}}, )) s.commit() yield db._reset_for_tests() @pytest.fixture def backup_svc(tmp_path): return svc_mod.BackupService( backup_dir=tmp_path / "backups", passphrase="test-pass", retention_days=7, ) # --------------------------------------------------------------------------- # tick # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_tick_creates_backup_and_audits_it(fresh_db, backup_svc): sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0) result = await sched.tick() assert result.ok assert result.created is not None assert result.error is None assert len(backup_svc.list_backups()) == 1 @pytest.mark.asyncio async def test_tick_creates_audit_event(fresh_db, backup_svc): """db.backup_created audit event is written (SP11 hash chain).""" sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0) await sched.tick() with db.SessionLocal()() as s: from cyclone.db import AuditLog rows = ( s.query(AuditLog) .filter(AuditLog.event_type == "db.backup_created") .all() ) assert len(rows) == 1 assert "backup_id" in rows[0].payload_json @pytest.mark.asyncio async def test_tick_handles_create_failure_without_crashing(fresh_db, backup_svc, monkeypatch): """If create_now raises, tick records the error and continues.""" def boom(): raise RuntimeError("simulated failure") monkeypatch.setattr(backup_svc, "create_now", boom) sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0) result = await sched.tick() assert result.error is not None assert "simulated failure" in result.error # Audit event written for the failure. with db.SessionLocal()() as s: from cyclone.db import AuditLog rows = ( s.query(AuditLog) .filter(AuditLog.event_type == "db.backup_failed") .all() ) assert len(rows) == 1 @pytest.mark.asyncio async def test_tick_prunes_old_backups_and_audits(fresh_db, backup_svc): """A tick prunes backups past retention and writes a db.backup_pruned event.""" from cyclone.db import DbBackup # Take an initial backup. initial = backup_svc.create_now() # Age it past retention. with db.SessionLocal()() as s: row = s.get(DbBackup, initial.backup.id) row.created_at = datetime.now(timezone.utc) - timedelta(days=30) s.commit() sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0) result = await sched.tick() assert result.ok # create_now succeeded even though prune removed old assert len(result.pruned_paths) == 2 # .bin + .meta.json with db.SessionLocal()() as s: from cyclone.db import AuditLog pruned_events = ( s.query(AuditLog) .filter(AuditLog.event_type == "db.backup_pruned") .all() ) assert len(pruned_events) == 1 # --------------------------------------------------------------------------- # start / stop / is_running # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_start_then_stop(fresh_db, backup_svc): sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0) assert not sched.is_running() await sched.start() assert sched.is_running() # Don't wait for the staggered first tick; just stop. await sched.stop() assert not sched.is_running() @pytest.mark.asyncio async def test_double_start_is_idempotent(fresh_db, backup_svc): sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0) await sched.start() await sched.start() # no-op assert sched.is_running() await sched.stop() @pytest.mark.asyncio async def test_concurrent_ticks_are_coalesced(fresh_db, backup_svc): """Two tick() calls in flight — second waits for first.""" sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0) r1, r2 = await asyncio.gather(sched.tick(), sched.tick()) # Both should succeed and produce a single backup (the second # call returned the first call's result, or ran back-to-back # and produced a second backup — both are valid coalescings). assert r1 is not None assert r2 is not None # No matter the order, exactly 1 backup should exist OR 2 if they # ran sequentially. The point of coalescing is no-overlap, so # both should be ok=True. assert r1.ok assert r2.ok # --------------------------------------------------------------------------- # status # --------------------------------------------------------------------------- def test_status_snapshot(fresh_db, backup_svc): sched = sched_mod.BackupScheduler(backup_svc, interval_hours=12.0) snap = sched.status() assert snap.running is False assert snap.interval_hours == 12.0 assert snap.backup_dir == str(backup_svc.backup_dir) assert snap.retention_days == 7 assert snap.tick_count == 0 assert snap.last_tick is None # --------------------------------------------------------------------------- # Module-level singleton # --------------------------------------------------------------------------- def test_module_singleton_round_trip(fresh_db, tmp_path): sched_mod.reset_backup_scheduler_for_tests() svc = svc_mod.BackupService(tmp_path / "b", passphrase="x", retention_days=1) sched = sched_mod.configure_backup_scheduler(svc, interval_hours=1) assert sched_mod.get_backup_scheduler() is sched # Second configure is a no-op. assert sched_mod.configure_backup_scheduler(svc) is sched sched_mod.reset_backup_scheduler_for_tests() def test_module_singleton_get_raises_when_unset(): sched_mod.reset_backup_scheduler_for_tests() with pytest.raises(RuntimeError): sched_mod.get_backup_scheduler()