gabriel / musehub public
test_raw_endpoint.py python
330 lines 13.0 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Tests for GET /{owner}/{repo_slug}/raw/{ref}/{path} endpoint.
2
3 Covers:
4 raw_file_semantic (ui_tree.py):
5 - 200: file exists in snapshot manifest and object exists in storage
6 - 404: file exists in manifest but object missing from storage
7 - 404: file not in snapshot manifest at ref
8 - 404: ref does not exist
9 - correct Content-Type for text files (.py, .toml, .md)
10 - correct Content-Type for binary files (.png)
11 - Content-Disposition: inline for text, attachment for binary
12
13 storage.exists() interface:
14 - BlobBackend.exists(object_id) — single argument, no repo_id
15 - BlobBackend satisfies the StorageBackend protocol
16 """
17 from __future__ import annotations
18
19 import secrets
20 from datetime import datetime, timezone
21 from unittest.mock import MagicMock, patch
22
23 import msgpack
24 import pytest
25 from httpx import AsyncClient
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from muse.core.types import fake_id
29 from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_repo_id
30 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubObject, MusehubRepo, MusehubSnapshot, MusehubSnapshotRef
31 from musehub.storage.backends import BlobBackend
32
33
34 MINIO_ENDPOINT = "http://localhost:9000"
35 MINIO_BUCKET = "muse-objects"
36 MINIO_ACCESS_KEY = "minioadmin"
37 MINIO_SECRET_KEY = "minioadmin"
38
39
40 def _uid() -> str:
41 return secrets.token_hex(16)
42
43
44 def _minio_backend() -> BlobBackend:
45 return BlobBackend(
46 bucket=MINIO_BUCKET,
47 endpoint_url=MINIO_ENDPOINT,
48 access_key_id=MINIO_ACCESS_KEY,
49 secret_access_key=MINIO_SECRET_KEY,
50 region="us-east-1",
51 )
52
53
54 # ── DB fixtures ───────────────────────────────────────────────────────────────
55
56 async def _make_repo(
57 db: AsyncSession,
58 owner: str = "gabriel",
59 slug: str = "muse",
60 ) -> MusehubRepo:
61 created_at = datetime.now(tz=timezone.utc)
62 owner_id = compute_identity_id(owner.encode())
63 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
64 repo = MusehubRepo(
65 repo_id=repo_id,
66 name=slug,
67 owner=owner,
68 slug=slug,
69 visibility="public",
70 owner_user_id=owner_id,
71 created_at=created_at,
72 updated_at=created_at,
73 )
74 db.add(repo)
75 await db.flush()
76 return repo
77
78
79 async def _make_snapshot(
80 db: AsyncSession,
81 repo_id: str,
82 manifest: dict[str, str],
83 ) -> MusehubSnapshot:
84 snap = MusehubSnapshot(
85 snapshot_id=fake_id(_uid()),
86 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
87 entry_count=len(manifest),
88 created_at=datetime.now(tz=timezone.utc),
89 )
90 db.add(snap)
91 db.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap.snapshot_id))
92 await db.flush()
93 return snap
94
95
96 async def _make_branch_at_commit(
97 db: AsyncSession,
98 repo_id: str,
99 branch_name: str,
100 manifest: dict[str, str],
101 ) -> tuple[MusehubCommit, MusehubSnapshot]:
102 snap = await _make_snapshot(db, repo_id, manifest)
103 now = datetime.now(tz=timezone.utc)
104 commit = MusehubCommit(
105 commit_id=fake_id(_uid()),
106 snapshot_id=snap.snapshot_id,
107 message="test commit",
108 author="gabriel",
109 branch=branch_name,
110 parent_ids=[],
111 timestamp=now,
112 created_at=now,
113 )
114 db.add(commit)
115 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit.commit_id))
116 await db.flush()
117 branch = MusehubBranch(
118 branch_id=compute_branch_id(repo_id, branch_name),
119 repo_id=repo_id,
120 name=branch_name,
121 head_commit_id=commit.commit_id,
122 )
123 db.add(branch)
124 await db.flush()
125 return commit, snap
126
127
128 async def _make_object_in_db(
129 db: AsyncSession,
130 object_id: str,
131 content: bytes,
132 path: str = "file",
133 ) -> MusehubObject:
134 """Insert a MusehubObject row with storage_uri pointing to MinIO."""
135 obj = MusehubObject(
136 object_id=object_id,
137 path=path,
138 size_bytes=len(content),
139 storage_uri=f"s3://{MINIO_BUCKET}/objects/{object_id}",
140 )
141 db.add(obj)
142 await db.flush()
143 return obj
144
145
146 # ═══════════════════════════════════════════════════════════════════════════════
147 # StorageBackend interface — exists() takes exactly one argument (object_id)
148 # ═══════════════════════════════════════════════════════════════════════════════
149
150 class TestStorageBackendExistsInterface:
151 """Regression: exists() must accept a single object_id, never (repo_id, object_id)."""
152
153 async def test_blob_backend_exists_single_arg(self) -> None:
154 mock_client = MagicMock()
155 mock_client.head_object.return_value = {}
156 backend = BlobBackend(bucket="test-bucket", region="us-east-1")
157 backend._client = mock_client
158 oid = fake_id("test-object")
159 result = await backend.exists(oid)
160 assert result is True
161 mock_client.head_object.assert_called_once_with(
162 Bucket="test-bucket", Key=f"objects/{oid}"
163 )
164
165 async def test_blob_backend_exists_error_returns_false(self) -> None:
166 """BlobBackend.exists() returns False when head_object fails."""
167 mock_client = MagicMock()
168 mock_client.head_object.side_effect = Exception("not found")
169 backend = BlobBackend(bucket="test-bucket", region="us-east-1")
170 backend._client = mock_client
171 result = await backend.exists(fake_id("test-object"))
172 assert result is False
173
174
175 # ═══════════════════════════════════════════════════════════════════════════════
176 # GET /{owner}/{repo_slug}/raw/{ref}/{path} — endpoint tests
177 # ═══════════════════════════════════════════════════════════════════════════════
178
179 class TestRawEndpoint:
180
181 async def test_returns_200_for_file_in_manifest_and_storage(
182 self, client: AsyncClient, db_session: AsyncSession
183 ) -> None:
184 repo = await _make_repo(db_session)
185 file_content = b"[tool.poetry]\nname = 'muse'\n"
186 oid = fake_id("pyproject-oid-" + _uid())
187 _, _ = await _make_branch_at_commit(
188 db_session, repo.repo_id, "main", {"pyproject.toml": oid}
189 )
190 await _make_object_in_db(db_session, oid, file_content, path="pyproject.toml")
191 await db_session.commit()
192
193 backend = _minio_backend()
194 await backend.put(oid, file_content)
195
196 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
197 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
198
199 assert resp.status_code == 200
200 assert resp.content == file_content
201
202 async def test_returns_404_when_file_not_in_manifest(
203 self, client: AsyncClient, db_session: AsyncSession
204 ) -> None:
205 repo = await _make_repo(db_session, slug="muse-raw2-" + _uid())
206 _, _ = await _make_branch_at_commit(
207 db_session, repo.repo_id, "main", {"README.md": fake_id("readme-oid")}
208 )
209 await db_session.commit()
210
211 backend = _minio_backend()
212 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
213 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
214
215 assert resp.status_code == 404
216 assert "pyproject.toml" in resp.json()["detail"]
217
218 async def test_returns_404_when_object_missing_from_storage(
219 self, client: AsyncClient, db_session: AsyncSession
220 ) -> None:
221 repo = await _make_repo(db_session, slug="muse-raw3-" + _uid())
222 oid = fake_id("missing-oid-" + _uid())
223 _, _ = await _make_branch_at_commit(
224 db_session, repo.repo_id, "main", {"pyproject.toml": oid}
225 )
226 await db_session.commit()
227
228 # Backend has no object written for this OID — exists() returns False
229 backend = _minio_backend()
230 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
231 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
232
233 assert resp.status_code == 404
234 # Must be distinct from the manifest-miss message so we can tell the two
235 # failure cases apart from logs/responses (critical for staging diagnosis).
236 assert "storage" in resp.json()["detail"].lower()
237
238 async def test_404_manifest_miss_and_storage_miss_have_distinct_messages(
239 self, client: AsyncClient, db_session: AsyncSession
240 ) -> None:
241 """Regression: the two 404 paths must produce different detail strings.
242
243 Without this the staging 404 is undiagnosable — we can't tell whether
244 the snapshot manifest has the file or whether the object is missing from R2.
245 """
246 backend = _minio_backend()
247
248 # Case A: file not in manifest at all
249 slug_a = "muse-raw3b-" + _uid()
250 repo_a = await _make_repo(db_session, slug=slug_a)
251 _, _ = await _make_branch_at_commit(
252 db_session, repo_a.repo_id, "main", {"README.md": fake_id("readme-oid")}
253 )
254 await db_session.commit()
255 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
256 resp_a = await client.get(f"/{repo_a.owner}/{repo_a.slug}/raw/main/pyproject.toml")
257
258 # Case B: file in manifest, object missing from storage
259 slug_b = "muse-raw3c-" + _uid()
260 repo_b = await _make_repo(db_session, slug=slug_b)
261 _, _ = await _make_branch_at_commit(
262 db_session, repo_b.repo_id, "main", {"pyproject.toml": fake_id("missing-oid-" + _uid())}
263 )
264 await db_session.commit()
265 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
266 resp_b = await client.get(f"/{repo_b.owner}/{repo_b.slug}/raw/main/pyproject.toml")
267
268 assert resp_a.status_code == 404
269 assert resp_b.status_code == 404
270 assert resp_a.json()["detail"] != resp_b.json()["detail"], (
271 "manifest-miss and storage-miss must produce different detail strings"
272 )
273
274 async def test_returns_404_for_unknown_ref(
275 self, client: AsyncClient, db_session: AsyncSession
276 ) -> None:
277 repo = await _make_repo(db_session, slug="muse-raw4-" + _uid())
278 _, _ = await _make_branch_at_commit(
279 db_session, repo.repo_id, "main", {"pyproject.toml": fake_id("oid")}
280 )
281 await db_session.commit()
282
283 backend = _minio_backend()
284 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
285 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/nonexistent-branch/pyproject.toml")
286
287 assert resp.status_code == 404
288
289 async def test_text_file_served_as_text_plain(
290 self, client: AsyncClient, db_session: AsyncSession
291 ) -> None:
292 repo = await _make_repo(db_session, slug="muse-raw5-" + _uid())
293 oid = fake_id("py-oid-" + _uid())
294 content = b"def main(): pass\n"
295 _, _ = await _make_branch_at_commit(
296 db_session, repo.repo_id, "main", {"musehub/main.py": oid}
297 )
298 await _make_object_in_db(db_session, oid, content, path="musehub/main.py")
299 await db_session.commit()
300
301 backend = _minio_backend()
302 await backend.put(oid, content)
303
304 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
305 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/musehub/main.py")
306
307 assert resp.status_code == 200
308 assert "text/plain" in resp.headers["content-type"]
309
310 async def test_binary_file_served_as_attachment(
311 self, client: AsyncClient, db_session: AsyncSession
312 ) -> None:
313 repo = await _make_repo(db_session, slug="muse-raw6-" + _uid())
314 oid = fake_id("png-oid-" + _uid())
315 png_bytes = b"\x89PNG\r\n\x1a\n"
316 _, _ = await _make_branch_at_commit(
317 db_session, repo.repo_id, "main", {"logo.png": oid}
318 )
319 await _make_object_in_db(db_session, oid, png_bytes, path="logo.png")
320 await db_session.commit()
321
322 backend = _minio_backend()
323 await backend.put(oid, png_bytes)
324
325 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
326 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/logo.png")
327
328 assert resp.status_code == 200
329 assert resp.headers["content-type"] == "image/png"
330 assert "attachment" in resp.headers["content-disposition"]
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago