"""TDD — fetch.mpack.prebuild job handler (issue #92 Phase 2). Test IDs: FMC_07 Unit: mock wire_fetch_mpack, confirm cache rows written for each tip, confirm existing fresh entries are skipped FMC_08 Integration: insert a job row, run the handler, verify MusehubFetchMPackCache row exists with correct repo_id/tip/mpack_id """ from __future__ import annotations import hashlib from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, patch import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from musehub.core.genesis import compute_job_id from musehub.db.musehub_jobs_models import MusehubBackgroundJob from musehub.db.musehub_repo_models import MusehubFetchMPackCache from musehub.services.musehub_wire_fetch import process_fetch_mpack_prebuild_job from tests.factories import create_repo def _now() -> datetime: return datetime.now(tz=timezone.utc) def _fake_commit_id(seed: str) -> str: return "sha256:" + hashlib.sha256(seed.encode()).hexdigest() def _fake_mpack_id(seed: str) -> str: return "sha256:" + hashlib.sha256(f"mpack-{seed}".encode()).hexdigest() async def _insert_job( session: AsyncSession, repo_id: str, tip_commit_ids: list[str], ) -> str: now = _now() job_id = compute_job_id(repo_id, "fetch.mpack.prebuild", now.isoformat()) session.add(MusehubBackgroundJob( job_id=job_id, repo_id=repo_id, job_type="fetch.mpack.prebuild", payload={"tip_commit_ids": tip_commit_ids}, status="pending", created_at=now, attempt=0, )) await session.flush() return job_id # ── FMC_07 ──────────────────────────────────────────────────────────────────── @pytest.mark.tier2 async def test_fmc_07_builds_each_tip_and_writes_cache(db_session: AsyncSession) -> None: """FMC_07a: handler calls wire_fetch_mpack once per tip and writes cache rows.""" repo = await create_repo(db_session, owner="gabriel", visibility="public") tip_a = _fake_commit_id("tip-a") tip_b = _fake_commit_id("tip-b") mpack_a = _fake_mpack_id("tip-a") mpack_b = _fake_mpack_id("tip-b") job_id = await _insert_job(db_session, repo.repo_id, [tip_a, tip_b]) await db_session.commit() side_effects = [ {"mpack_id": mpack_a, "mpack_url": "https://r2.example/a", "commit_count": 1, "blob_count": 2}, {"mpack_id": mpack_b, "mpack_url": "https://r2.example/b", "commit_count": 1, "blob_count": 3}, ] with patch( "musehub.services.musehub_wire_fetch.wire_fetch_mpack", new_callable=AsyncMock, side_effect=side_effects, ) as mock_build: result = await process_fetch_mpack_prebuild_job(db_session, job_id) await db_session.commit() assert mock_build.call_count == 2 assert result["tips_requested"] == 2 assert result["tips_built"] == 2 assert result["tips_skipped"] == 0 rows = (await db_session.execute( select(MusehubFetchMPackCache) .where(MusehubFetchMPackCache.repo_id == repo.repo_id) .order_by(MusehubFetchMPackCache.tip_commit_id) )).scalars().all() assert len(rows) == 2 mpack_ids = {r.tip_commit_id: r.mpack_id for r in rows} assert mpack_ids[tip_a] == mpack_a assert mpack_ids[tip_b] == mpack_b @pytest.mark.tier2 async def test_fmc_07b_skips_tips_with_fresh_cache(db_session: AsyncSession) -> None: """FMC_07b: tips with a non-expired cache entry are skipped without calling wire_fetch_mpack.""" repo = await create_repo(db_session, owner="gabriel", visibility="public") tip_cached = _fake_commit_id("tip-cached") tip_new = _fake_commit_id("tip-new") existing_mpack = _fake_mpack_id("existing") new_mpack = _fake_mpack_id("new") # Pre-populate a fresh cache entry for tip_cached. cache_id = hashlib.sha256((repo.repo_id + tip_cached).encode()).hexdigest() db_session.add(MusehubFetchMPackCache( cache_id=cache_id, repo_id=repo.repo_id, tip_commit_id=tip_cached, mpack_id=existing_mpack, created_at=_now(), expires_at=_now() + timedelta(days=7), )) job_id = await _insert_job(db_session, repo.repo_id, [tip_cached, tip_new]) await db_session.commit() with patch( "musehub.services.musehub_wire_fetch.wire_fetch_mpack", new_callable=AsyncMock, return_value={"mpack_id": new_mpack, "mpack_url": "https://r2.example/new", "commit_count": 1, "blob_count": 1}, ) as mock_build: result = await process_fetch_mpack_prebuild_job(db_session, job_id) await db_session.commit() # Only the new tip should have triggered a build. assert mock_build.call_count == 1 assert mock_build.call_args[1]["want"] == [tip_new] or mock_build.call_args[0][2] == [tip_new] assert result["tips_built"] == 1 assert result["tips_skipped"] == 1 # The pre-existing cache entry must be unchanged. cached_row = (await db_session.execute( select(MusehubFetchMPackCache) .where(MusehubFetchMPackCache.repo_id == repo.repo_id) .where(MusehubFetchMPackCache.tip_commit_id == tip_cached) )).scalar_one() assert cached_row.mpack_id == existing_mpack @pytest.mark.tier2 async def test_fmc_07c_empty_payload_is_a_noop(db_session: AsyncSession) -> None: """FMC_07c: job with no tip_commit_ids returns zeros without calling wire_fetch_mpack.""" repo = await create_repo(db_session, owner="gabriel", visibility="public") job_id = await _insert_job(db_session, repo.repo_id, []) await db_session.commit() with patch( "musehub.services.musehub_wire_fetch.wire_fetch_mpack", new_callable=AsyncMock, ) as mock_build: result = await process_fetch_mpack_prebuild_job(db_session, job_id) assert mock_build.call_count == 0 assert result["tips_requested"] == 0 assert result["tips_built"] == 0 # ── FMC_08 ──────────────────────────────────────────────────────────────────── @pytest.mark.tier2 async def test_fmc_08_cache_row_has_correct_fields(db_session: AsyncSession) -> None: """FMC_08: after the handler runs, cache row has matching repo_id, tip, and mpack_id.""" repo = await create_repo(db_session, owner="gabriel", visibility="public") tip = _fake_commit_id("integration-tip") mpack = _fake_mpack_id("integration") job_id = await _insert_job(db_session, repo.repo_id, [tip]) await db_session.commit() with patch( "musehub.services.musehub_wire_fetch.wire_fetch_mpack", new_callable=AsyncMock, return_value={"mpack_id": mpack, "mpack_url": "https://r2.example/int", "commit_count": 5, "blob_count": 10}, ): await process_fetch_mpack_prebuild_job(db_session, job_id) await db_session.commit() row = (await db_session.execute( select(MusehubFetchMPackCache) .where(MusehubFetchMPackCache.repo_id == repo.repo_id) .where(MusehubFetchMPackCache.tip_commit_id == tip) )).scalar_one() assert row.repo_id == repo.repo_id assert row.tip_commit_id == tip assert row.mpack_id == mpack assert row.expires_at > _now()