gabriel / musehub public
test_quota_via_refs.py python
145 lines 5.5 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago
1 """TDD: quota queries use musehub_object_refs, not musehub_objects.repo_id.
2
3 Coverage matrix:
4 1. wire_push quota: exceeded quota is rejected (push/stream).
5 2. get_repo_stats: total_objects and total_size_bytes use refs join.
6 3. get_repo_stats: shared object counted once per repo.
7 """
8 from __future__ import annotations
9
10 import secrets
11 from unittest.mock import patch
12
13 import msgpack
14 import pytest
15 import sqlalchemy as sa
16 from sqlalchemy.ext.asyncio import AsyncSession
17
18 from muse.core.types import blob_id, fake_id
19 from musehub.db import musehub_models as db
20 from musehub.types.json_types import JSONValue, StrDict
21 from tests.factories import create_repo
22
23
24 def _oid(raw: bytes) -> str:
25 return blob_id(raw)
26
27
28 def _mp(data: JSONValue) -> bytes:
29 return msgpack.packb(data, use_bin_type=True)
30
31
32 # ---------------------------------------------------------------------------
33 # Test 1: push rejected when quota exceeded
34 # ---------------------------------------------------------------------------
35
36 @pytest.mark.asyncio
37 async def test_push_rejected_when_quota_exceeded(
38 db_session: AsyncSession,
39 ) -> None:
40 """wire_push_stream must reject pushes that would exceed per_repo_quota_bytes."""
41 import msgpack
42 from muse.core.mpack import MuseWireFrameWriter
43 from musehub.services.musehub_wire import wire_push_stream
44 from musehub.models.wire import SFRAME_HEADER, SFRAME_OBJECT, SFRAME_COMMIT_PACK, SFRAME_END
45
46 raw = b"x" * 100
47 oid = _oid(raw)
48 repo = await create_repo(db_session, slug=f"quota-reject-{secrets.token_hex(4)}", owner="test-user-wire")
49
50 fw = MuseWireFrameWriter()
51
52 def _wrap(ft: str, data: JSONValue) -> bytes:
53 return fw.wrap(frame_type=ft, payload=msgpack.packb(data, use_bin_type=True))
54
55 body = (
56 _wrap(SFRAME_HEADER, {"t": SFRAME_HEADER, "branch": "main", "force": False,
57 "have": [], "head": fake_id("push-head"), "n_objects": 1, "n_commits": 0})
58 + _wrap(SFRAME_OBJECT, {"t": SFRAME_OBJECT, "id": oid, "content": raw, "path": "big.md", "enc": "raw"})
59 + _wrap(SFRAME_COMMIT_PACK, {"t": SFRAME_COMMIT_PACK, "commits": [], "snapshots": []})
60 + _wrap(SFRAME_END, {"t": SFRAME_END, "n_objects": 1, "n_commits": 0})
61 )
62
63 async def body_iter() -> None:
64 yield body
65
66 frames: list[dict] = []
67 with patch("musehub.services.musehub_wire.settings") as mock_settings:
68 mock_settings.per_repo_quota_bytes = 1 # 1 byte — always exceeded
69 mock_settings.require_signed_commits = False
70 mock_settings.trusted_agent_ids = []
71 async for chunk in wire_push_stream(db_session, repo.repo_id, body_iter(), pusher_id="test-user-wire"):
72 unpacker = msgpack.Unpacker(raw=False)
73 unpacker.feed(chunk)
74 frames.extend(list(unpacker))
75
76 result = frames[-1] if frames else {}
77 assert result.get("ok") is not True # X frame (error) has no "ok"; R frame has ok=False
78 msg = (result.get("msg") or result.get("message") or "").lower()
79 assert "quota" in msg, f"rejection message must mention quota; got: {msg}"
80
81
82 # ---------------------------------------------------------------------------
83 # Test 2: get_repo_stats counts and sizes via refs join
84 # ---------------------------------------------------------------------------
85
86 @pytest.mark.asyncio
87 async def test_repo_stats_uses_refs_join(
88 db_session: AsyncSession,
89 ) -> None:
90 """get_repo_stats total_objects and total_size_bytes must count via refs."""
91 from musehub.services.musehub_repository import get_repo_home_stats
92
93 repo = await create_repo(db_session, slug=f"stats-refs-{secrets.token_hex(4)}", owner="test-user-wire")
94
95 # Insert an object + ref directly
96 oid = _oid(b"stats test object content")
97 obj = db.MusehubObject(
98 object_id=oid,
99 path="stats.md",
100 size_bytes=42,
101 disk_path="",
102 )
103 db_session.add(obj)
104 db_session.add(db.MusehubObjectRef(repo_id=repo.repo_id, object_id=oid))
105 await db_session.commit()
106
107 stats = await get_repo_home_stats(db_session, repo.repo_id, ref="main")
108
109 assert stats["total_objects"] == 1
110 assert stats["total_size_bytes"] == 42
111
112
113 # ---------------------------------------------------------------------------
114 # Test 3: get_repo_stats shared object counted once per repo
115 # ---------------------------------------------------------------------------
116
117 @pytest.mark.asyncio
118 async def test_repo_stats_shared_object_per_repo(
119 db_session: AsyncSession,
120 ) -> None:
121 """A shared object must appear in each repo's stats independently."""
122 from musehub.services.musehub_repository import get_repo_home_stats
123
124 repo_a = await create_repo(db_session, slug=f"stats-shared-a-{secrets.token_hex(4)}", owner="test-user-wire")
125 repo_b = await create_repo(db_session, slug=f"stats-shared-b-{secrets.token_hex(4)}", owner="test-user-wire")
126
127 oid = _oid(b"shared stats object")
128 obj = db.MusehubObject(
129 object_id=oid,
130 path="shared.md",
131 size_bytes=100,
132 disk_path="",
133 )
134 db_session.add(obj)
135 db_session.add(db.MusehubObjectRef(repo_id=repo_a.repo_id, object_id=oid))
136 db_session.add(db.MusehubObjectRef(repo_id=repo_b.repo_id, object_id=oid))
137 await db_session.commit()
138
139 stats_a = await get_repo_home_stats(db_session, repo_a.repo_id, ref="main")
140 stats_b = await get_repo_home_stats(db_session, repo_b.repo_id, ref="main")
141
142 assert stats_a["total_objects"] == 1
143 assert stats_a["total_size_bytes"] == 100
144 assert stats_b["total_objects"] == 1
145 assert stats_b["total_size_bytes"] == 100
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago