gabriel / musehub public
test_raw_endpoint.py python
313 lines 12.9 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 142 days 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 - LocalBackend.exists(object_id) — single argument, no repo_id
15 - S3Backend.exists(object_id) — single argument, no repo_id
16 - Both satisfy the StorageBackend protocol
17 """
18 from __future__ import annotations
19
20 import uuid
21 from datetime import datetime, timezone
22 from pathlib import Path
23 from unittest.mock import MagicMock, patch
24
25 import msgpack
26 import pytest
27 from httpx import AsyncClient
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from muse.core.types import fake_id
31 from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_repo_id
32 from musehub.db.musehub_models import (
33 MusehubBranch,
34 MusehubCommit,
35 MusehubRepo,
36 MusehubSnapshot,
37 )
38 from musehub.storage.backends import LocalBackend, S3Backend
39
40
41 def _uid() -> str:
42 return str(uuid.uuid4())
43
44
45 # ── DB fixtures ───────────────────────────────────────────────────────────────
46
47 async def _make_repo(
48 db: AsyncSession,
49 owner: str = "gabriel",
50 slug: str = "muse",
51 ) -> MusehubRepo:
52 created_at = datetime.now(tz=timezone.utc)
53 owner_id = compute_identity_id(owner.encode())
54 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
55 repo = MusehubRepo(
56 repo_id=repo_id,
57 name=slug,
58 owner=owner,
59 slug=slug,
60 visibility="public",
61 owner_user_id=owner_id,
62 created_at=created_at,
63 updated_at=created_at,
64 )
65 db.add(repo)
66 await db.flush()
67 return repo
68
69
70 async def _make_snapshot(
71 db: AsyncSession,
72 repo_id: str,
73 manifest: dict[str, str],
74 ) -> MusehubSnapshot:
75 snap = MusehubSnapshot(
76 snapshot_id=fake_id(_uid()),
77 repo_id=repo_id,
78 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
79 entry_count=len(manifest),
80 created_at=datetime.now(tz=timezone.utc),
81 )
82 db.add(snap)
83 await db.flush()
84 return snap
85
86
87 async def _make_branch_at_commit(
88 db: AsyncSession,
89 repo_id: str,
90 branch_name: str,
91 manifest: dict[str, str],
92 ) -> tuple[MusehubCommit, MusehubSnapshot]:
93 snap = await _make_snapshot(db, repo_id, manifest)
94 now = datetime.now(tz=timezone.utc)
95 commit = MusehubCommit(
96 commit_id=fake_id(_uid()),
97 repo_id=repo_id,
98 snapshot_id=snap.snapshot_id,
99 message="test commit",
100 author="gabriel",
101 branch=branch_name,
102 parent_ids=[],
103 timestamp=now,
104 created_at=now,
105 commit_meta={},
106 )
107 db.add(commit)
108 await db.flush()
109 branch = MusehubBranch(
110 branch_id=compute_branch_id(repo_id, branch_name),
111 repo_id=repo_id,
112 name=branch_name,
113 head_commit_id=commit.commit_id,
114 )
115 db.add(branch)
116 await db.flush()
117 return commit, snap
118
119
120 # ═══════════════════════════════════════════════════════════════════════════════
121 # StorageBackend interface — exists() takes exactly one argument (object_id)
122 # ═══════════════════════════════════════════════════════════════════════════════
123
124 class TestStorageBackendExistsInterface:
125 """Regression: exists() must accept a single object_id, never (repo_id, object_id)."""
126
127 async def test_local_backend_exists_single_arg(self, tmp_path: Path) -> None:
128 backend = LocalBackend(objects_dir=str(tmp_path / "objects"))
129 oid = fake_id("test-object")
130 # Should not raise — single argument only
131 result = await backend.exists(oid)
132 assert result is False
133
134 async def test_local_backend_exists_returns_true_after_put(self, tmp_path: Path) -> None:
135 backend = LocalBackend(objects_dir=str(tmp_path / "objects"))
136 oid = fake_id("test-object")
137 await backend.put(oid, b"hello world")
138 assert await backend.exists(oid) is True
139
140 async def test_local_backend_exists_two_args_raises(self, tmp_path: Path) -> None:
141 """Calling exists(repo_id, object_id) must raise TypeError — catches regressions."""
142 backend = LocalBackend(objects_dir=str(tmp_path / "objects"))
143 with pytest.raises(TypeError):
144 await backend.exists("some-repo-id", fake_id("test-object")) # type: ignore[call-arg]
145
146 async def test_s3_backend_exists_single_arg(self) -> None:
147 mock_client = MagicMock()
148 mock_client.head_object.return_value = {}
149 backend = S3Backend(bucket="test-bucket", region="us-east-1")
150 backend._client = mock_client
151 oid = fake_id("test-object")
152 result = await backend.exists(oid)
153 assert result is True
154 mock_client.head_object.assert_called_once_with(
155 Bucket="test-bucket", Key=f"objects/{oid.replace(':', '_')}"
156 )
157
158 async def test_s3_backend_exists_two_args_raises(self) -> None:
159 """Same regression guard for S3Backend."""
160 backend = S3Backend(bucket="test-bucket", region="us-east-1")
161 with pytest.raises(TypeError):
162 await backend.exists("some-repo-id", fake_id("test-object")) # type: ignore[call-arg]
163
164
165 # ═══════════════════════════════════════════════════════════════════════════════
166 # GET /{owner}/{repo_slug}/raw/{ref}/{path} — endpoint tests
167 # ═══════════════════════════════════════════════════════════════════════════════
168
169 class TestRawEndpoint:
170
171 async def test_returns_200_for_file_in_manifest_and_storage(
172 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
173 ) -> None:
174 repo = await _make_repo(db_session)
175 file_content = b"[tool.poetry]\nname = 'muse'\n"
176 oid = fake_id("pyproject-oid")
177 _, _ = await _make_branch_at_commit(
178 db_session, repo.repo_id, "main", {"pyproject.toml": oid}
179 )
180 await db_session.commit()
181
182 backend = LocalBackend(objects_dir=str(tmp_path / "objects"))
183 await backend.put(oid, file_content)
184
185 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
186 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
187
188 assert resp.status_code == 200
189 assert resp.content == file_content
190
191 async def test_returns_404_when_file_not_in_manifest(
192 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
193 ) -> None:
194 repo = await _make_repo(db_session, slug="muse2")
195 _, _ = await _make_branch_at_commit(
196 db_session, repo.repo_id, "main", {"README.md": fake_id("readme-oid")}
197 )
198 await db_session.commit()
199
200 backend = LocalBackend(objects_dir=str(tmp_path / "objects"))
201 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
202 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
203
204 assert resp.status_code == 404
205 assert "pyproject.toml" in resp.json()["detail"]
206
207 async def test_returns_404_when_object_missing_from_storage(
208 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
209 ) -> None:
210 repo = await _make_repo(db_session, slug="muse3")
211 oid = fake_id("missing-oid")
212 _, _ = await _make_branch_at_commit(
213 db_session, repo.repo_id, "main", {"pyproject.toml": oid}
214 )
215 await db_session.commit()
216
217 # Backend has no objects written — exists() returns False
218 backend = LocalBackend(objects_dir=str(tmp_path / "objects"))
219 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
220 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
221
222 assert resp.status_code == 404
223 # Must be distinct from the manifest-miss message so we can tell the two
224 # failure cases apart from logs/responses (critical for staging diagnosis).
225 assert "storage" in resp.json()["detail"].lower()
226
227 async def test_404_manifest_miss_and_storage_miss_have_distinct_messages(
228 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
229 ) -> None:
230 """Regression: the two 404 paths must produce different detail strings.
231
232 Without this the staging 404 is undiagnosable — we can't tell whether
233 the snapshot manifest has the file or whether the object is missing from R2.
234 """
235 backend = LocalBackend(objects_dir=str(tmp_path / "objects"))
236
237 # Case A: file not in manifest at all
238 repo_a = await _make_repo(db_session, slug="muse3b")
239 _, _ = await _make_branch_at_commit(
240 db_session, repo_a.repo_id, "main", {"README.md": fake_id("readme-oid")}
241 )
242 await db_session.commit()
243 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
244 resp_a = await client.get(f"/{repo_a.owner}/{repo_a.slug}/raw/main/pyproject.toml")
245
246 # Case B: file in manifest, object missing from storage
247 repo_b = await _make_repo(db_session, slug="muse3c")
248 _, _ = await _make_branch_at_commit(
249 db_session, repo_b.repo_id, "main", {"pyproject.toml": fake_id("missing-oid")}
250 )
251 await db_session.commit()
252 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
253 resp_b = await client.get(f"/{repo_b.owner}/{repo_b.slug}/raw/main/pyproject.toml")
254
255 assert resp_a.status_code == 404
256 assert resp_b.status_code == 404
257 assert resp_a.json()["detail"] != resp_b.json()["detail"], (
258 "manifest-miss and storage-miss must produce different detail strings"
259 )
260
261 async def test_returns_404_for_unknown_ref(
262 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
263 ) -> None:
264 repo = await _make_repo(db_session, slug="muse4")
265 _, _ = await _make_branch_at_commit(
266 db_session, repo.repo_id, "main", {"pyproject.toml": fake_id("oid")}
267 )
268 await db_session.commit()
269
270 backend = LocalBackend(objects_dir=str(tmp_path / "objects"))
271 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
272 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/nonexistent-branch/pyproject.toml")
273
274 assert resp.status_code == 404
275
276 async def test_text_file_served_as_text_plain(
277 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
278 ) -> None:
279 repo = await _make_repo(db_session, slug="muse5")
280 oid = fake_id("py-oid")
281 _, _ = await _make_branch_at_commit(
282 db_session, repo.repo_id, "main", {"musehub/main.py": oid}
283 )
284 await db_session.commit()
285
286 backend = LocalBackend(objects_dir=str(tmp_path / "objects"))
287 await backend.put(oid, b"def main(): pass\n")
288
289 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
290 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/musehub/main.py")
291
292 assert resp.status_code == 200
293 assert "text/plain" in resp.headers["content-type"]
294
295 async def test_binary_file_served_as_attachment(
296 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
297 ) -> None:
298 repo = await _make_repo(db_session, slug="muse6")
299 oid = fake_id("png-oid")
300 _, _ = await _make_branch_at_commit(
301 db_session, repo.repo_id, "main", {"logo.png": oid}
302 )
303 await db_session.commit()
304
305 backend = LocalBackend(objects_dir=str(tmp_path / "objects"))
306 await backend.put(oid, b"\x89PNG\r\n\x1a\n") # PNG magic bytes
307
308 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
309 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/logo.png")
310
311 assert resp.status_code == 200
312 assert resp.headers["content-type"] == "image/png"
313 assert "attachment" in resp.headers["content-disposition"]
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 142 days ago