"""TDD: quota queries use musehub_object_refs, not musehub_objects.repo_id. Phase 4 of the object-refs architecture: storage accounting must walk the refs table so that content-addressed dedup is correctly attributed. Two repos that share an object must each pay for it independently (the object exists once in storage but is intentionally charged to each repo that references it). Coverage matrix: 1. wire_push quota: shared object is counted for both repos independently. 2. wire_push quota: dedup push to same repo is not double-counted. 3. wire_push quota: exceeded quota is rejected. 4. get_repo_stats: total_objects and total_size_bytes use refs join. 5. get_repo_stats: shared object counted once per repo. """ from __future__ import annotations import hashlib import uuid import zlib from unittest.mock import AsyncMock, MagicMock, patch import msgpack import pytest import sqlalchemy as sa from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from musehub.db import musehub_models as db from musehub.types.json_types import JSONValue, StrDict from tests.factories import create_repo def _oid(raw: bytes) -> str: return "sha256:" + hashlib.sha256(raw).hexdigest() def _mp(data: JSONValue) -> bytes: return msgpack.packb(data, use_bin_type=True) def _zlib(raw: bytes) -> bytes: return zlib.compress(raw, level=1) # --------------------------------------------------------------------------- # Test 1: shared object is counted for both repos independently # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_quota_counts_shared_object_per_repo( client: AsyncClient, db_session: AsyncSession, wire_headers: StrDict, ) -> None: """Two repos pushing the same bytes each count that object toward their quota. This is correct accounting: both repos reference the object and would each need to pay for it if the other repo were deleted. """ raw = b"# shared object for quota test\n" * 50 oid = _oid(raw) repo_a = await create_repo(db_session, slug=f"quota-shared-a-{uuid.uuid4().hex[:8]}", owner="test-user-wire") repo_b = await create_repo(db_session, slug=f"quota-shared-b-{uuid.uuid4().hex[:8]}", owner="test-user-wire") # Push to repo_a first r_a = await client.post( f"/{repo_a.owner}/{repo_a.slug}/push/object-pack", content=_mp({"objects": [{"object_id": oid, "content": _zlib(raw), "encoding": "zlib", "path": "shared.md"}]}), headers=wire_headers, ) assert r_a.status_code == 200 # Push same bytes to repo_b r_b = await client.post( f"/{repo_b.owner}/{repo_b.slug}/push/object-pack", content=_mp({"objects": [{"object_id": oid, "content": _zlib(raw), "encoding": "zlib", "path": "shared.md"}]}), headers=wire_headers, ) assert r_b.status_code == 200 # Both repos should have a ref for repo in (repo_a, repo_b): ref = (await db_session.execute( sa.select(db.MusehubObjectRef).where( db.MusehubObjectRef.repo_id == repo.repo_id, db.MusehubObjectRef.object_id == oid, ) )).scalar_one_or_none() assert ref is not None, f"repo {repo.slug} must have a ref row" # Only one row in musehub_objects (dedup invariant) obj_count = (await db_session.execute( sa.select(sa.func.count()).select_from(db.MusehubObject).where( db.MusehubObject.object_id == oid ) )).scalar_one() assert obj_count == 1, "dedup: one object row" # Quota query via refs: each repo counts the object def _quota_for(repo_id: str): return ( sa.select(sa.func.coalesce(sa.func.sum(db.MusehubObject.size_bytes), 0)) .join(db.MusehubObjectRef, db.MusehubObject.object_id == db.MusehubObjectRef.object_id) .where(db.MusehubObjectRef.repo_id == repo_id) ) size_a = (await db_session.execute(_quota_for(repo_a.repo_id))).scalar_one() size_b = (await db_session.execute(_quota_for(repo_b.repo_id))).scalar_one() assert size_a > 0, "repo_a quota must count the shared object" assert size_b > 0, "repo_b quota must count the shared object" assert size_a == size_b, "both repos pay the same amount for the shared object" # --------------------------------------------------------------------------- # Test 2: re-pushing same object to same repo does not double-count quota # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_quota_idempotent_push_no_double_count( client: AsyncClient, db_session: AsyncSession, wire_headers: StrDict, ) -> None: """Pushing the same object twice to the same repo must not inflate quota.""" raw = b"# idempotent quota test object\n" * 40 oid = _oid(raw) repo = await create_repo(db_session, slug=f"quota-idem-{uuid.uuid4().hex[:8]}", owner="test-user-wire") url = f"/{repo.owner}/{repo.slug}/push/object-pack" payload = _mp({"objects": [{"object_id": oid, "content": _zlib(raw), "encoding": "zlib", "path": "idem.md"}]}) for _ in range(3): r = await client.post(url, content=payload, headers=wire_headers) assert r.status_code == 200 ref_count = (await db_session.execute( sa.select(sa.func.count()).select_from(db.MusehubObjectRef).where( db.MusehubObjectRef.repo_id == repo.repo_id, db.MusehubObjectRef.object_id == oid, ) )).scalar_one() assert ref_count == 1, "idempotent push must not create duplicate refs" quota_used = (await db_session.execute( sa.select(sa.func.coalesce(sa.func.sum(db.MusehubObject.size_bytes), 0)) .join(db.MusehubObjectRef, db.MusehubObject.object_id == db.MusehubObjectRef.object_id) .where(db.MusehubObjectRef.repo_id == repo.repo_id) )).scalar_one() # Must equal exactly one copy of the object's size obj_size = (await db_session.execute( sa.select(db.MusehubObject.size_bytes).where(db.MusehubObject.object_id == oid) )).scalar_one() assert quota_used == obj_size, \ f"quota must count the object once; got {quota_used} vs obj_size={obj_size}" # --------------------------------------------------------------------------- # Test 3: push rejected when quota exceeded # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_push_rejected_when_quota_exceeded( db_session: AsyncSession, ) -> None: """wire_push must reject pushes that would exceed per_repo_quota_bytes.""" from musehub.services.musehub_wire import wire_push from musehub.models.wire import WirePushRequest, WireBundle, WireObject raw = b"x" * 100 oid = _oid(raw) repo = await create_repo(db_session, slug=f"quota-reject-{uuid.uuid4().hex[:8]}", owner="test-user-wire") bundle = WireBundle( objects=[WireObject(object_id=oid, path="big.md", content=raw)], commits=[], snapshots=[], ) req = WirePushRequest(repo_id=repo.repo_id, branch="main", bundle=bundle) with patch("musehub.services.musehub_wire.settings") as mock_settings: mock_settings.per_repo_quota_bytes = 1 # 1 byte — always exceeded result = await wire_push(db_session, repo.repo_id, req, pusher_id="test-user-wire") assert result.ok is False assert "quota" in result.message.lower(), \ f"rejection message must mention quota; got: {result.message}" # --------------------------------------------------------------------------- # Test 4: get_repo_stats counts and sizes via refs join # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_repo_stats_uses_refs_join( db_session: AsyncSession, ) -> None: """get_repo_stats total_objects and total_size_bytes must count via refs.""" from musehub.services.musehub_repository import get_repo_home_stats repo = await create_repo(db_session, slug=f"stats-refs-{uuid.uuid4().hex[:8]}", owner="test-user-wire") # Insert an object + ref directly oid = _oid(b"stats test object content") obj = db.MusehubObject( object_id=oid, path="stats.md", size_bytes=42, disk_path="", ) db_session.add(obj) db_session.add(db.MusehubObjectRef(repo_id=repo.repo_id, object_id=oid)) await db_session.commit() stats = await get_repo_home_stats(db_session, repo.repo_id, ref="main") assert stats["total_objects"] == 1 assert stats["total_size_bytes"] == 42 # --------------------------------------------------------------------------- # Test 5: get_repo_stats shared object counted once per repo # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_repo_stats_shared_object_per_repo( db_session: AsyncSession, ) -> None: """A shared object must appear in each repo's stats independently.""" from musehub.services.musehub_repository import get_repo_home_stats repo_a = await create_repo(db_session, slug=f"stats-shared-a-{uuid.uuid4().hex[:8]}", owner="test-user-wire") repo_b = await create_repo(db_session, slug=f"stats-shared-b-{uuid.uuid4().hex[:8]}", owner="test-user-wire") oid = _oid(b"shared stats object") obj = db.MusehubObject( object_id=oid, path="shared.md", size_bytes=100, disk_path="", ) db_session.add(obj) # Both repos hold a ref db_session.add(db.MusehubObjectRef(repo_id=repo_a.repo_id, object_id=oid)) db_session.add(db.MusehubObjectRef(repo_id=repo_b.repo_id, object_id=oid)) await db_session.commit() stats_a = await get_repo_home_stats(db_session, repo_a.repo_id, ref="main") stats_b = await get_repo_home_stats(db_session, repo_b.repo_id, ref="main") assert stats_a["total_objects"] == 1 assert stats_a["total_size_bytes"] == 100 assert stats_b["total_objects"] == 1 assert stats_b["total_size_bytes"] == 100