"""TDD — fetch-mpack cache cleanup via GC (issue #47, redesigned by fetch-mpack-cache). Issue #47 originally modeled the presigned mpack as a *transient* artifact that ``wire_fetch_mpack`` deleted itself after ``ttl_seconds``. The fetch-mpack-cache feature inverted that contract: the mpack is now a *cached, reusable* artifact — one row per ``(repo, tip)`` in ``musehub_fetch_mpack_cache``, kept until ``expires_at`` — so fresh-clone requests (``want=[tip], have=[]``) hit a prebuilt mpack instead of rebuilding it synchronously (which caused Cloudflare 524s on large repos). A per-request delete would 404 every subsequent cache hit, so cleanup moved to :func:`gc_fetch_mpack_cache`, which deletes expired rows and their R2 objects after every GC run. These tests verify that GC cleanup contract — the same guarantees the original per-request tests protected, now at their real home: BC0 An expired cache entry → ``backend.delete(mpack_id)`` is called and the row removed. BC2 ``backend.delete`` raising does not propagate — GC is best-effort and still removes the expired row (no orphaned cache rows). BC3 Only expired entries are deleted (exactly once each); fresh entries survive. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id from musehub.db.musehub_repo_models import MusehubFetchMPackCache from tests.factories import create_repo # ── helpers ─────────────────────────────────────────────────────────────────── def _stub_backend(monkeypatch: pytest.MonkeyPatch, *, delete_raises: bool = False) -> AsyncMock: """Patch the storage backend that gc_fetch_mpack_cache resolves; return its mock.""" backend = AsyncMock() backend.delete = AsyncMock( side_effect=RuntimeError("MinIO unavailable") if delete_raises else None ) monkeypatch.setattr("musehub.services.musehub_gc.get_backend", lambda: backend) return backend async def _insert_cache_row( session: AsyncSession, repo_id: str, *, mpack_id: str, expired: bool, seed: str, ) -> None: """Insert one musehub_fetch_mpack_cache row, expired (past) or fresh (future).""" now = datetime.now(tz=timezone.utc) session.add( MusehubFetchMPackCache( cache_id=fake_id(f"cache-{seed}"), repo_id=repo_id, tip_commit_id=fake_id(f"tip-{seed}"), mpack_id=mpack_id, created_at=now, expires_at=now - timedelta(minutes=5) if expired else now + timedelta(days=7), ) ) await session.commit() async def _remaining_mpack_ids(session: AsyncSession, repo_id: str) -> list[str]: rows = await session.execute( select(MusehubFetchMPackCache.mpack_id).where( MusehubFetchMPackCache.repo_id == repo_id ) ) return list(rows.scalars().all()) # ══════════════════════════════════════════════════════════════════════════════ # BC0 — expired cache entry: backend.delete(mpack_id) called, row removed # ══════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_bc0_expired_mpack_deleted_by_gc( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """gc_fetch_mpack_cache deletes the R2 object for an expired entry and drops the row.""" from musehub.services.musehub_gc import gc_fetch_mpack_cache backend = _stub_backend(monkeypatch) repo = await create_repo(db_session, owner="gabriel", visibility="public") mpack_id = fake_id("mpack-bc0") await _insert_cache_row(db_session, repo.repo_id, mpack_id=mpack_id, expired=True, seed="bc0") deleted = await gc_fetch_mpack_cache(db_session, repo.repo_id) assert deleted == 1 backend.delete.assert_awaited_once_with(mpack_id) assert await _remaining_mpack_ids(db_session, repo.repo_id) == [] # ══════════════════════════════════════════════════════════════════════════════ # BC2 — backend.delete failure does not propagate (best-effort cleanup) # ══════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_bc2_gc_delete_failure_is_swallowed( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """A backend.delete error must not crash GC, and the expired row is still removed.""" from musehub.services.musehub_gc import gc_fetch_mpack_cache backend = _stub_backend(monkeypatch, delete_raises=True) repo = await create_repo(db_session, owner="gabriel", visibility="public") mpack_id = fake_id("mpack-bc2") await _insert_cache_row(db_session, repo.repo_id, mpack_id=mpack_id, expired=True, seed="bc2") # Must not raise even though backend.delete raises. deleted = await gc_fetch_mpack_cache(db_session, repo.repo_id) assert deleted == 1, "expired row removed even when the R2 delete fails" backend.delete.assert_awaited_once() assert await _remaining_mpack_ids(db_session, repo.repo_id) == [] # ══════════════════════════════════════════════════════════════════════════════ # BC3 — only expired entries deleted (once each); fresh entries survive # ══════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_bc3_gc_deletes_only_expired_once( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """Exactly one delete for the expired entry; the fresh entry is left untouched.""" from musehub.services.musehub_gc import gc_fetch_mpack_cache backend = _stub_backend(monkeypatch) repo = await create_repo(db_session, owner="gabriel", visibility="public") expired_mpack = fake_id("mpack-bc3-expired") fresh_mpack = fake_id("mpack-bc3-fresh") await _insert_cache_row(db_session, repo.repo_id, mpack_id=expired_mpack, expired=True, seed="bc3-exp") await _insert_cache_row(db_session, repo.repo_id, mpack_id=fresh_mpack, expired=False, seed="bc3-fresh") deleted = await gc_fetch_mpack_cache(db_session, repo.repo_id) assert deleted == 1, "only the expired entry is GC'd" backend.delete.assert_awaited_once_with(expired_mpack) assert await _remaining_mpack_ids(db_session, repo.repo_id) == [fresh_mpack]