gabriel / musehub public
test_releases.py python
1,200 lines 44.6 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Section 12 — Releases & Release Packager: 7-layer test suite.
2
3 Layer 1 Unit
4 - TestUnitParseChangelog: _parse_changelog happy/edge/malformed paths
5 - TestUnitParseSemanticReport: _parse_semantic_report round-trip and failure modes
6 - TestUnitBuildDownloadUrls: packager URL construction
7 - TestUnitBuildEmptyDownloadUrls: empty URL sentinel
8 - TestUnitIsPrerelease: is_prerelease derived from channel
9
10 Layer 2 Integration
11 - TestIntegrationCreateRelease: tag uniqueness, channel validation, semver fields stored
12 - TestIntegrationListReleases: newest-first order, draft exclusion, channel filter
13 - TestIntegrationGetByTag: found / not found
14 - TestIntegrationDeleteByTag: returns True/False, cascade to assets
15 - TestIntegrationAssets: attach, list, remove, download count increment
16
17 Layer 3 E2E
18 - TestE2EReleaseLifecycle: HTTP create → get → list → attach asset → download stats → delete asset
19 - TestE2EDuplicateTag: second create with same tag → 409
20 - TestE2EDraftRelease: draft excluded from list, visible with include_drafts
21 - TestE2ECreateFromDict: create_release_from_dict wire format
22 - TestE2ETags: list_tags endpoint, namespace extraction
23
24 Layer 4 Stress
25 - TestStress: 30 releases, 20 assets on one release
26
27 Layer 5 Data Integrity
28 - TestDataIntegrity: tag uniqueness per-repo (not cross-repo), asset FK cascade,
29 download count atomic increment, semver fields persisted correctly
30
31 Layer 6 Security
32 - TestSecurity: create/delete/attach endpoints require auth;
33 private repo list/get/tags require auth; download tracking no auth for public
34
35 Layer 7 Performance
36 - TestPerformance: list 100 releases <500ms, list 50 assets <200ms
37 """
38 from __future__ import annotations
39
40 import secrets
41 import time
42 import pytest
43 from httpx import AsyncClient
44 from sqlalchemy.ext.asyncio import AsyncSession
45
46 from datetime import datetime, timezone
47
48 from muse.core.types import fake_id
49 from musehub.core.genesis import compute_identity_id, compute_release_id, compute_repo_id
50 from musehub.db.musehub_repo_models import MusehubRepo
51 from musehub.types.json_types import JSONObject, StrDict
52 from musehub.models.musehub import (
53 ReleaseResponse,
54 ChangelogEntryResponse,
55 ReleaseDownloadUrls,
56 SemanticReleaseReportResponse,
57 )
58 from musehub.services.musehub_release_packager import (
59 build_download_urls,
60 build_empty_download_urls,
61 )
62 from musehub.services.musehub_releases import (
63 _parse_changelog,
64 _parse_semantic_report,
65 )
66
67
68 # ===========================================================================
69 # Helpers
70 # ===========================================================================
71
72
73 def _uid() -> str:
74 return secrets.token_hex(16)
75
76
77 async def _repo(
78 session: AsyncSession,
79 slug: str,
80 owner: str = "alice",
81 visibility: str = "public",
82 ) -> MusehubRepo:
83 owner_id = compute_identity_id(owner.encode())
84 created_at = datetime.now(tz=timezone.utc)
85 repo = MusehubRepo(
86 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
87 name=slug,
88 owner=owner,
89 slug=slug,
90 visibility=visibility,
91 owner_user_id=owner_id,
92 created_at=created_at,
93 updated_at=created_at,
94 )
95 session.add(repo)
96 await session.flush()
97 await session.refresh(repo)
98 return repo
99
100
101 async def _release(
102 session: AsyncSession,
103 repo_id: str,
104 tag: str = "v1.0.0",
105 *,
106 channel: str = "stable",
107 is_draft: bool = False,
108 semver_major: int = 1,
109 semver_minor: int = 0,
110 semver_patch: int = 0,
111 ) -> ReleaseResponse:
112 from musehub.services import musehub_releases
113 return await musehub_releases.create_release(
114 session,
115 repo_id=repo_id,
116 tag=tag,
117 commit_id=None,
118 channel=channel,
119 is_draft=is_draft,
120 semver_major=semver_major,
121 semver_minor=semver_minor,
122 semver_patch=semver_patch,
123 )
124
125
126 async def _api_repo(
127 client: AsyncClient, auth_headers: StrDict, name: str, visibility: str = "public"
128 ) -> str:
129 r = await client.post(
130 "/api/repos",
131 json={"name": name, "owner": "testuser", "initialize": False, "visibility": visibility},
132 headers=auth_headers,
133 )
134 assert r.status_code == 201, r.text
135 return str(r.json()["repoId"])
136
137
138 async def _api_release(
139 client: AsyncClient,
140 auth_headers: StrDict,
141 repo_id: str,
142 tag: str = "v1.0.0",
143 **kwargs: str | bool,
144 ) -> JSONObject:
145 payload = {"tag": tag, "channel": "stable", **kwargs}
146 r = await client.post(
147 f"/api/repos/{repo_id}/releases",
148 json=payload,
149 headers=auth_headers,
150 )
151 assert r.status_code == 201, r.text
152 return dict(r.json())
153
154
155 # ===========================================================================
156 # Layer 1 — Unit tests
157 # ===========================================================================
158
159
160 class TestUnitParseChangelog:
161 def test_valid_entries(self) -> None:
162 raw = '[{"commit_id":"abc","message":"feat: add x","sem_ver_bump":"minor","breaking_changes":[],"author":"alice","timestamp":"2025-01-01"}]'
163 entries = _parse_changelog(raw)
164 assert len(entries) == 1
165 assert entries[0].commit_id == "abc"
166 assert entries[0].message == "feat: add x"
167 assert entries[0].sem_ver_bump == "minor"
168
169 def test_empty_json_array(self) -> None:
170 assert _parse_changelog("[]") == []
171
172 def test_invalid_json_returns_empty(self) -> None:
173 assert _parse_changelog("{not valid json") == []
174
175 def test_none_like_returns_empty(self) -> None:
176 assert _parse_changelog("") == []
177
178 def test_non_list_root_returns_empty(self) -> None:
179 assert _parse_changelog('{"key": "value"}') == []
180
181 def test_non_dict_items_skipped(self) -> None:
182 raw = '[1, "string", {"commit_id":"x","message":"m"}]'
183 entries = _parse_changelog(raw)
184 assert len(entries) == 1
185 assert entries[0].commit_id == "x"
186
187 def test_missing_fields_default_to_empty(self) -> None:
188 raw = '[{"commit_id":"y","message":"fix"}]'
189 entries = _parse_changelog(raw)
190 assert entries[0].sem_ver_bump == ""
191 assert entries[0].breaking_changes == []
192 assert entries[0].author == ""
193
194 def test_breaking_changes_list(self) -> None:
195 raw = '[{"commit_id":"z","message":"break","breaking_changes":["Drop support for X"]}]'
196 entries = _parse_changelog(raw)
197 assert entries[0].breaking_changes == ["Drop support for X"]
198
199 def test_wrong_type_fields_handled(self) -> None:
200 # breaking_changes is not a list — should default gracefully
201 raw = '[{"commit_id":"a","message":"b","breaking_changes":"not-a-list"}]'
202 entries = _parse_changelog(raw)
203 assert entries[0].breaking_changes == []
204
205
206 class TestUnitParseSemanticReport:
207 def test_valid_report_round_trips(self) -> None:
208 report = SemanticReleaseReportResponse(
209 total_files=10,
210 semantic_files=8,
211 total_symbols=42,
212 human_commits=3,
213 agent_commits=1,
214 )
215 raw = report.model_dump_json()
216 parsed = _parse_semantic_report(raw)
217 assert parsed is not None
218 assert parsed.total_files == 10
219 assert parsed.total_symbols == 42
220 assert parsed.agent_commits == 1
221
222 def test_empty_string_returns_none(self) -> None:
223 assert _parse_semantic_report("") is None
224
225 def test_invalid_json_returns_none(self) -> None:
226 assert _parse_semantic_report("{bad json") is None
227
228 def test_non_dict_returns_none(self) -> None:
229 assert _parse_semantic_report("[1,2,3]") is None
230
231 def test_partial_report_fills_defaults(self) -> None:
232 raw = '{"total_files": 5}'
233 parsed = _parse_semantic_report(raw)
234 assert parsed is not None
235 assert parsed.total_files == 5
236 assert parsed.total_symbols == 0 # default
237
238 def test_unknown_extra_fields_ignored(self) -> None:
239 # Pydantic ignores extra fields by default
240 raw = '{"total_files": 2, "unknown_future_field": "ignored"}'
241 parsed = _parse_semantic_report(raw)
242 assert parsed is not None
243 assert parsed.total_files == 2
244
245
246 class TestUnitBuildDownloadUrls:
247 def test_metadata_url_contains_repo_and_release(self) -> None:
248 urls = build_download_urls("my-repo-id", "my-release-id")
249 assert isinstance(urls, ReleaseDownloadUrls)
250 assert urls.metadata is not None
251 assert "my-repo-id" in urls.metadata
252 assert "my-release-id" in urls.metadata
253
254 def test_metadata_url_ends_with_metadata(self) -> None:
255 urls = build_download_urls("r", "rel")
256 assert urls.metadata is not None
257 assert urls.metadata.endswith("/metadata")
258
259
260 class TestUnitBuildEmptyDownloadUrls:
261 def test_all_fields_are_none(self) -> None:
262 urls = build_empty_download_urls()
263 assert isinstance(urls, ReleaseDownloadUrls)
264 assert urls.metadata is None
265
266
267 class TestUnitIsPrerelease:
268 def test_stable_channel_not_prerelease(self) -> None:
269 from musehub.models.musehub import ReleaseResponse
270 from datetime import datetime, timezone
271 _now = datetime.now(tz=timezone.utc)
272 r = ReleaseResponse(
273 release_id=compute_release_id(fake_id("repo"), "v1.0.0", _now.isoformat()),
274 tag="v1.0.0", channel="stable",
275 download_urls=ReleaseDownloadUrls(),
276 created_at=_now,
277 )
278 assert r.is_prerelease is False
279
280 def test_beta_channel_is_prerelease(self) -> None:
281 from musehub.models.musehub import ReleaseResponse
282 from datetime import datetime, timezone
283 _now = datetime.now(tz=timezone.utc)
284 r = ReleaseResponse(
285 release_id=compute_release_id(fake_id("repo"), "v2.0.0-beta.1", _now.isoformat()),
286 tag="v2.0.0-beta.1", channel="beta",
287 download_urls=ReleaseDownloadUrls(),
288 created_at=_now,
289 )
290 assert r.is_prerelease is True
291
292 def test_alpha_channel_is_prerelease(self) -> None:
293 from musehub.models.musehub import ReleaseResponse
294 from datetime import datetime, timezone
295 _now = datetime.now(tz=timezone.utc)
296 r = ReleaseResponse(
297 release_id=compute_release_id(fake_id("repo"), "v3.0.0-alpha.1", _now.isoformat()),
298 tag="v3.0.0-alpha.1", channel="alpha",
299 download_urls=ReleaseDownloadUrls(),
300 created_at=_now,
301 )
302 assert r.is_prerelease is True
303
304 def test_nightly_channel_is_prerelease(self) -> None:
305 from musehub.models.musehub import ReleaseResponse
306 from datetime import datetime, timezone
307 _now = datetime.now(tz=timezone.utc)
308 r = ReleaseResponse(
309 release_id=compute_release_id(fake_id("repo"), "v4.0.0-nightly", _now.isoformat()),
310 tag="v4.0.0-nightly", channel="nightly",
311 download_urls=ReleaseDownloadUrls(),
312 created_at=_now,
313 )
314 assert r.is_prerelease is True
315
316
317 # ===========================================================================
318 # Layer 2 — Integration tests
319 # ===========================================================================
320
321
322 class TestIntegrationCreateRelease:
323 async def test_creates_with_all_semver_fields(
324 self, db_session: AsyncSession
325 ) -> None:
326 repo = await _repo(db_session, "semver-fields")
327 r = await _release(
328 db_session, repo.repo_id, "v3.2.1",
329 semver_major=3, semver_minor=2, semver_patch=1,
330 )
331 assert r.semver_major == 3
332 assert r.semver_minor == 2
333 assert r.semver_patch == 1
334 assert r.tag == "v3.2.1"
335
336 async def test_duplicate_tag_raises_value_error(
337 self, db_session: AsyncSession
338 ) -> None:
339 repo = await _repo(db_session, "dup-tag")
340 await _release(db_session, repo.repo_id, "v1.0.0")
341 with pytest.raises(ValueError, match="already exists"):
342 await _release(db_session, repo.repo_id, "v1.0.0")
343
344 async def test_invalid_channel_raises_value_error(
345 self, db_session: AsyncSession
346 ) -> None:
347 from musehub.services import musehub_releases
348 repo = await _repo(db_session, "bad-channel")
349 with pytest.raises(ValueError, match="Unknown channel"):
350 await musehub_releases.create_release(
351 db_session, repo_id=repo.repo_id,
352 tag="v1.0.0", commit_id=None, channel="underground",
353 )
354
355 async def test_valid_channels_accepted(
356 self, db_session: AsyncSession
357 ) -> None:
358 for i, channel in enumerate(["stable", "beta", "alpha", "nightly"]):
359 repo = await _repo(db_session, f"chan-{channel}")
360 r = await _release(db_session, repo.repo_id, "v1.0.0", channel=channel)
361 assert r.channel == channel
362
363 async def test_semantic_report_stored_and_parsed(
364 self, db_session: AsyncSession
365 ) -> None:
366 from musehub.services import musehub_releases
367 repo = await _repo(db_session, "semreport")
368 report = SemanticReleaseReportResponse(
369 total_files=5, total_symbols=20, agent_commits=2
370 )
371 r = await musehub_releases.create_release(
372 db_session, repo_id=repo.repo_id,
373 tag="v1.0.0", commit_id=None,
374 semantic_report_json=report.model_dump_json(),
375 )
376 assert r.semantic_report is not None
377 assert r.semantic_report.total_files == 5
378 assert r.semantic_report.agent_commits == 2
379
380 async def test_changelog_stored_and_parsed(
381 self, db_session: AsyncSession
382 ) -> None:
383 from musehub.services import musehub_releases
384 repo = await _repo(db_session, "changelog-stored")
385 entry = ChangelogEntryResponse(
386 commit_id="abc123", message="feat: new thing", sem_ver_bump="minor"
387 )
388 r = await musehub_releases.create_release(
389 db_session, repo_id=repo.repo_id,
390 tag="v1.0.0", commit_id=None, changelog=[entry],
391 )
392 assert len(r.changelog) == 1
393 assert r.changelog[0].commit_id == "abc123"
394
395
396 class TestIntegrationListReleases:
397 async def test_newest_first_ordering(
398 self, db_session: AsyncSession
399 ) -> None:
400 from musehub.services import musehub_releases
401 repo = await _repo(db_session, "order-test")
402 await _release(db_session, repo.repo_id, "v1.0.0")
403 await _release(db_session, repo.repo_id, "v2.0.0")
404 await _release(db_session, repo.repo_id, "v3.0.0")
405
406 releases = await musehub_releases.list_releases(db_session, repo.repo_id)
407 assert releases.releases[0].tag == "v3.0.0"
408 assert releases.releases[-1].tag == "v1.0.0"
409
410 async def test_drafts_excluded_by_default(
411 self, db_session: AsyncSession
412 ) -> None:
413 from musehub.services import musehub_releases
414 repo = await _repo(db_session, "draft-excl")
415 await _release(db_session, repo.repo_id, "v1.0.0")
416 await _release(db_session, repo.repo_id, "v2.0.0-draft", is_draft=True)
417
418 releases = await musehub_releases.list_releases(db_session, repo.repo_id)
419 assert releases.total == 1
420 assert releases.releases[0].tag == "v1.0.0"
421
422 async def test_drafts_included_when_requested(
423 self, db_session: AsyncSession
424 ) -> None:
425 from musehub.services import musehub_releases
426 repo = await _repo(db_session, "draft-incl")
427 await _release(db_session, repo.repo_id, "v1.0.0")
428 await _release(db_session, repo.repo_id, "v2.0.0-draft", is_draft=True)
429
430 releases = await musehub_releases.list_releases(
431 db_session, repo.repo_id, include_drafts=True
432 )
433 assert releases.total == 2
434
435 async def test_channel_filter(
436 self, db_session: AsyncSession
437 ) -> None:
438 from musehub.services import musehub_releases
439 repo = await _repo(db_session, "chan-filter")
440 await _release(db_session, repo.repo_id, "v1.0.0", channel="stable")
441 await _release(db_session, repo.repo_id, "v2.0.0-beta.1", channel="beta")
442
443 betas = await musehub_releases.list_releases(
444 db_session, repo.repo_id, channel="beta"
445 )
446 assert betas.total == 1
447 assert betas.releases[0].channel == "beta"
448
449
450 class TestIntegrationGetByTag:
451 async def test_found_by_tag(self, db_session: AsyncSession) -> None:
452 from musehub.services import musehub_releases
453 repo = await _repo(db_session, "get-by-tag")
454 await _release(db_session, repo.repo_id, "v5.0.0")
455 result = await musehub_releases.get_release_by_tag(db_session, repo.repo_id, "v5.0.0")
456 assert result is not None
457 assert result.tag == "v5.0.0"
458
459 async def test_not_found_returns_none(self, db_session: AsyncSession) -> None:
460 from musehub.services import musehub_releases
461 repo = await _repo(db_session, "get-by-tag-miss")
462 result = await musehub_releases.get_release_by_tag(db_session, repo.repo_id, "v99.0.0")
463 assert result is None
464
465
466 class TestIntegrationDeleteByTag:
467 async def test_delete_returns_true_when_found(
468 self, db_session: AsyncSession
469 ) -> None:
470 from musehub.services import musehub_releases
471 repo = await _repo(db_session, "del-true")
472 await _release(db_session, repo.repo_id, "v1.0.0")
473 result = await musehub_releases.delete_release_by_tag(db_session, repo.repo_id, "v1.0.0")
474 assert result is True
475
476 async def test_delete_returns_false_when_not_found(
477 self, db_session: AsyncSession
478 ) -> None:
479 from musehub.services import musehub_releases
480 repo = await _repo(db_session, "del-false")
481 result = await musehub_releases.delete_release_by_tag(db_session, repo.repo_id, "v9.9.9")
482 assert result is False
483
484 async def test_delete_removes_from_list(
485 self, db_session: AsyncSession
486 ) -> None:
487 from musehub.services import musehub_releases
488 repo = await _repo(db_session, "del-list")
489 await _release(db_session, repo.repo_id, "v1.0.0")
490 await musehub_releases.delete_release_by_tag(db_session, repo.repo_id, "v1.0.0")
491 releases = await musehub_releases.list_releases(db_session, repo.repo_id)
492 assert releases.total == 0
493
494
495 class TestIntegrationAssets:
496 async def test_attach_and_list_asset(
497 self, db_session: AsyncSession
498 ) -> None:
499 from musehub.services import musehub_releases
500 repo = await _repo(db_session, "asset-attach")
501 r = await _release(db_session, repo.repo_id, "v1.0.0")
502
503 asset = await musehub_releases.attach_asset(
504 db_session,
505 release_id=r.release_id,
506 repo_id=repo.repo_id,
507 name="mpack.zip",
508 label="MPack",
509 content_type="application/zip",
510 size=1024,
511 download_url="https://cdn.example.com/mpack.zip",
512 )
513 assert asset.name == "mpack.zip"
514 assert asset.size == 1024
515 assert asset.download_count == 0
516
517 assets = await musehub_releases.list_release_assets(
518 db_session, r.release_id, "v1.0.0"
519 )
520 assert assets.release_id == r.release_id
521 assert len(assets.assets) == 1
522
523 async def test_download_count_increment(
524 self, db_session: AsyncSession
525 ) -> None:
526 from musehub.services import musehub_releases
527 repo = await _repo(db_session, "dl-count")
528 r = await _release(db_session, repo.repo_id, "v1.0.0")
529 asset = await musehub_releases.attach_asset(
530 db_session,
531 release_id=r.release_id,
532 repo_id=repo.repo_id,
533 name="file.zip",
534 download_url="https://cdn.example.com/file.zip",
535 )
536
537 await musehub_releases.increment_asset_download_count(db_session, asset.asset_id)
538 await musehub_releases.increment_asset_download_count(db_session, asset.asset_id)
539 await db_session.flush()
540
541 stats = await musehub_releases.get_download_stats(
542 db_session, r.release_id, r.tag
543 )
544 assert stats.total_downloads == 2
545 assert stats.assets[0].download_count == 2
546
547 async def test_remove_asset_returns_true(
548 self, db_session: AsyncSession
549 ) -> None:
550 from musehub.services import musehub_releases
551 repo = await _repo(db_session, "asset-remove")
552 r = await _release(db_session, repo.repo_id, "v1.0.0")
553 asset = await musehub_releases.attach_asset(
554 db_session,
555 release_id=r.release_id,
556 repo_id=repo.repo_id,
557 name="to-remove.zip",
558 download_url="https://cdn.example.com/rm.zip",
559 )
560 result = await musehub_releases.remove_asset(db_session, asset.asset_id)
561 assert result is True
562
563 async def test_remove_nonexistent_asset_returns_false(
564 self, db_session: AsyncSession
565 ) -> None:
566 from musehub.services import musehub_releases
567 result = await musehub_releases.remove_asset(db_session, _uid())
568 assert result is False
569
570 async def test_increment_nonexistent_asset_returns_false(
571 self, db_session: AsyncSession
572 ) -> None:
573 from musehub.services import musehub_releases
574 result = await musehub_releases.increment_asset_download_count(db_session, _uid())
575 assert result is False
576
577
578 # ===========================================================================
579 # Layer 3 — E2E tests
580 # ===========================================================================
581
582
583 class TestE2EReleaseLifecycle:
584 async def test_create_get_list(
585 self, client: AsyncClient, auth_headers: StrDict
586 ) -> None:
587 repo_id = await _api_repo(client, auth_headers, "e2e-lifecycle")
588 r = await _api_release(client, auth_headers, repo_id, "v1.0.0",
589 title="First Release", body="Initial release notes")
590 assert r["tag"] == "v1.0.0"
591 assert r["title"] == "First Release"
592 assert r["channel"] == "stable"
593 assert r["isPrerelease"] is False
594 release_id = r["releaseId"]
595
596 # GET by tag
597 r2 = await client.get(f"/api/repos/{repo_id}/releases/v1.0.0")
598 assert r2.status_code == 200
599 assert r2.json()["releaseId"] == release_id
600
601 # LIST
602 r3 = await client.get(f"/api/repos/{repo_id}/releases")
603 assert r3.status_code == 200
604 releases = r3.json()["releases"]
605 assert len(releases) == 1
606 assert releases[0]["tag"] == "v1.0.0"
607
608 async def test_attach_asset_and_download_stats(
609 self, client: AsyncClient, auth_headers: StrDict
610 ) -> None:
611 repo_id = await _api_repo(client, auth_headers, "e2e-assets")
612 await _api_release(client, auth_headers, repo_id, "v1.0.0")
613
614 # Attach asset
615 r = await client.post(
616 f"/api/repos/{repo_id}/releases/v1.0.0/assets",
617 json={
618 "name": "mpack.zip",
619 "label": "Main MPack",
620 "contentType": "application/zip",
621 "size": 2048,
622 "downloadUrl": "https://cdn.example.com/mpack.zip",
623 },
624 headers=auth_headers,
625 )
626 assert r.status_code == 201
627 asset_id = r.json()["assetId"]
628 assert r.json()["name"] == "mpack.zip"
629 assert r.json()["downloadCount"] == 0
630
631 # Record download
632 r2 = await client.post(
633 f"/api/repos/{repo_id}/releases/v1.0.0/assets/{asset_id}/download"
634 )
635 assert r2.status_code == 204
636
637 # Get download stats
638 r3 = await client.get(
639 f"/api/repos/{repo_id}/releases/v1.0.0/downloads"
640 )
641 assert r3.status_code == 200
642 data = r3.json()
643 assert data["totalDownloads"] == 1
644 assert data["assets"][0]["downloadCount"] == 1
645
646 async def test_delete_asset(
647 self, client: AsyncClient, auth_headers: StrDict
648 ) -> None:
649 repo_id = await _api_repo(client, auth_headers, "e2e-del-asset")
650 await _api_release(client, auth_headers, repo_id, "v1.0.0")
651
652 r = await client.post(
653 f"/api/repos/{repo_id}/releases/v1.0.0/assets",
654 json={"name": "file.zip", "downloadUrl": "https://cdn.example.com/f.zip"},
655 headers=auth_headers,
656 )
657 asset_id = r.json()["assetId"]
658
659 r2 = await client.delete(
660 f"/api/repos/{repo_id}/releases/v1.0.0/assets/{asset_id}",
661 headers=auth_headers,
662 )
663 assert r2.status_code == 204
664
665 # Asset list should be empty
666 r3 = await client.get(f"/api/repos/{repo_id}/releases/v1.0.0/assets")
667 assert r3.status_code == 200
668 assert r3.json()["assets"] == []
669
670
671 class TestE2EDuplicateTag:
672 async def test_second_create_same_tag_returns_409(
673 self, client: AsyncClient, auth_headers: StrDict
674 ) -> None:
675 repo_id = await _api_repo(client, auth_headers, "e2e-dup-tag")
676 await _api_release(client, auth_headers, repo_id, "v1.0.0")
677
678 r = await client.post(
679 f"/api/repos/{repo_id}/releases",
680 json={"tag": "v1.0.0"},
681 headers=auth_headers,
682 )
683 assert r.status_code == 409
684
685
686 class TestE2EDraftRelease:
687 async def test_draft_excluded_from_list(
688 self, client: AsyncClient, auth_headers: StrDict
689 ) -> None:
690 repo_id = await _api_repo(client, auth_headers, "e2e-draft")
691 await _api_release(client, auth_headers, repo_id, "v1.0.0")
692 await _api_release(client, auth_headers, repo_id, "v2.0.0", isDraft=True)
693
694 r = await client.get(f"/api/repos/{repo_id}/releases")
695 assert r.status_code == 200
696 tags = [rel["tag"] for rel in r.json()["releases"]]
697 assert "v1.0.0" in tags
698 assert "v2.0.0" not in tags
699
700 async def test_draft_accessible_by_tag(
701 self, client: AsyncClient, auth_headers: StrDict
702 ) -> None:
703 repo_id = await _api_repo(client, auth_headers, "e2e-draft-get")
704 await _api_release(client, auth_headers, repo_id, "v1.0.0-draft", isDraft=True)
705
706 r = await client.get(f"/api/repos/{repo_id}/releases/v1.0.0-draft")
707 assert r.status_code == 200
708 assert r.json()["isDraft"] is True
709
710 async def test_beta_channel_is_prerelease_in_response(
711 self, client: AsyncClient, auth_headers: StrDict
712 ) -> None:
713 repo_id = await _api_repo(client, auth_headers, "e2e-beta")
714 r = await _api_release(client, auth_headers, repo_id, "v2.0.0-beta.1", channel="beta")
715 assert r["channel"] == "beta"
716 assert r["isPrerelease"] is True
717
718
719 class TestE2ECreateFromDict:
720 async def test_create_from_dict_minimal(
721 self, db_session: AsyncSession
722 ) -> None:
723 from musehub.services import musehub_releases
724 repo = await _repo(db_session, "from-dict-min")
725 r = await musehub_releases.create_release_from_dict(
726 db_session, repo.repo_id,
727 {"tag": "v1.0.0", "title": "CLI Release"},
728 )
729 assert r.tag == "v1.0.0"
730 assert r.title == "CLI Release"
731
732 async def test_create_from_dict_missing_tag_raises(
733 self, db_session: AsyncSession
734 ) -> None:
735 from musehub.services import musehub_releases
736 repo = await _repo(db_session, "from-dict-notag")
737 with pytest.raises(ValueError, match="missing required field 'tag'"):
738 await musehub_releases.create_release_from_dict(
739 db_session, repo.repo_id, {"title": "No tag"}
740 )
741
742 async def test_create_from_dict_full_payload(
743 self, db_session: AsyncSession
744 ) -> None:
745 from musehub.services import musehub_releases
746 repo = await _repo(db_session, "from-dict-full")
747 data = {
748 "tag": "v3.1.4",
749 "title": "Full Release",
750 "body": "Release notes here",
751 "channel": "beta",
752 "agent_id": "agent-1",
753 "model_id": "claude-sonnet-4-6",
754 "is_draft": False,
755 }
756 r = await musehub_releases.create_release_from_dict(db_session, repo.repo_id, data)
757 assert r.tag == "v3.1.4"
758 assert r.channel == "beta"
759 assert r.agent_id == "agent-1"
760
761
762 class TestE2ETags:
763 async def test_list_tags_returns_all_releases_as_tags(
764 self, client: AsyncClient, auth_headers: StrDict
765 ) -> None:
766 repo_id = await _api_repo(client, auth_headers, "e2e-tags")
767 await _api_release(client, auth_headers, repo_id, "v1.0.0")
768 await _api_release(client, auth_headers, repo_id, "v2.0.0")
769
770 r = await client.get(f"/api/repos/{repo_id}/tags")
771 assert r.status_code == 200
772 tags = [t["tag"] for t in r.json()["tags"]]
773 assert "v1.0.0" in tags
774 assert "v2.0.0" in tags
775
776 async def test_tag_namespace_version_for_semver(
777 self, client: AsyncClient, auth_headers: StrDict
778 ) -> None:
779 repo_id = await _api_repo(client, auth_headers, "e2e-ns-ver")
780 await _api_release(client, auth_headers, repo_id, "v1.0.0")
781
782 r = await client.get(f"/api/repos/{repo_id}/tags")
783 assert r.status_code == 200
784 tag_obj = next(t for t in r.json()["tags"] if t["tag"] == "v1.0.0")
785 assert tag_obj["namespace"] == "version"
786
787 async def test_unknown_tag_returns_404(
788 self, client: AsyncClient, auth_headers: StrDict
789 ) -> None:
790 repo_id = await _api_repo(client, auth_headers, "e2e-tag-404")
791 r = await client.get(f"/api/repos/{repo_id}/releases/v99.0.0")
792 assert r.status_code == 404
793
794
795 # ===========================================================================
796 # Layer 4 — Stress tests
797 # ===========================================================================
798
799
800 class TestStress:
801 async def test_30_releases_sequential(
802 self, db_session: AsyncSession
803 ) -> None:
804 from musehub.services import musehub_releases
805 repo = await _repo(db_session, "stress-30")
806
807 for i in range(30):
808 await musehub_releases.create_release(
809 db_session,
810 repo_id=repo.repo_id,
811 tag=f"v{i}.0.0",
812 commit_id=None,
813 )
814
815 releases = await musehub_releases.list_releases(db_session, repo.repo_id)
816 assert releases.total == 30
817
818 async def test_20_assets_on_one_release(
819 self, db_session: AsyncSession
820 ) -> None:
821 from musehub.services import musehub_releases
822 repo = await _repo(db_session, "stress-assets")
823 r = await _release(db_session, repo.repo_id, "v1.0.0")
824
825 for i in range(20):
826 await musehub_releases.attach_asset(
827 db_session,
828 release_id=r.release_id,
829 repo_id=repo.repo_id,
830 name=f"asset-{i}.zip",
831 download_url=f"https://cdn.example.com/asset-{i}.zip",
832 )
833
834 assets = await musehub_releases.list_release_assets(db_session, r.release_id, "v1.0.0")
835 assert len(assets.assets) == 20
836
837
838 # ===========================================================================
839 # Layer 5 — Data Integrity tests
840 # ===========================================================================
841
842
843 class TestDataIntegrity:
844 async def test_tag_unique_per_repo_not_cross_repo(
845 self, db_session: AsyncSession
846 ) -> None:
847 from musehub.services import musehub_releases
848 r1 = await _repo(db_session, "di-r1")
849 r2 = await _repo(db_session, "di-r2")
850
851 await _release(db_session, r1.repo_id, "v1.0.0")
852 # Same tag in different repo is fine
853 r = await musehub_releases.create_release(
854 db_session, repo_id=r2.repo_id, tag="v1.0.0", commit_id=None
855 )
856 assert r.tag == "v1.0.0"
857
858 async def test_semver_fields_persisted_correctly(
859 self, db_session: AsyncSession
860 ) -> None:
861 from sqlalchemy import select as sa_select
862 from musehub.db.musehub_release_models import MusehubRelease
863 from musehub.services import musehub_releases
864
865 repo = await _repo(db_session, "di-semver")
866 r = await musehub_releases.create_release(
867 db_session, repo_id=repo.repo_id, tag="v4.5.6",
868 commit_id=None,
869 semver_major=4, semver_minor=5, semver_patch=6,
870 semver_pre="rc.1", semver_build="20250101",
871 )
872
873 stmt = sa_select(MusehubRelease).where(MusehubRelease.release_id == r.release_id)
874 row = (await db_session.execute(stmt)).scalar_one()
875 assert row.semver_major == 4
876 assert row.semver_minor == 5
877 assert row.semver_patch == 6
878 assert row.semver_pre == "rc.1"
879 assert row.semver_build == "20250101"
880
881 async def test_asset_cascade_deleted_with_release(
882 self, db_session: AsyncSession
883 ) -> None:
884 from sqlalchemy import select as sa_select
885 from musehub.db.musehub_release_models import MusehubReleaseAsset
886 from musehub.services import musehub_releases
887
888 repo = await _repo(db_session, "di-cascade")
889 r = await _release(db_session, repo.repo_id, "v1.0.0")
890 await musehub_releases.attach_asset(
891 db_session,
892 release_id=r.release_id,
893 repo_id=repo.repo_id,
894 name="file.zip",
895 download_url="https://cdn.example.com/file.zip",
896 )
897 await db_session.commit()
898
899 await musehub_releases.delete_release_by_tag(db_session, repo.repo_id, "v1.0.0")
900 await db_session.commit()
901
902 stmt = sa_select(MusehubReleaseAsset).where(
903 MusehubReleaseAsset.release_id == r.release_id
904 )
905 assets = (await db_session.execute(stmt)).scalars().all()
906 assert len(assets) == 0
907
908 async def test_download_count_starts_at_zero(
909 self, db_session: AsyncSession
910 ) -> None:
911 from musehub.services import musehub_releases
912 repo = await _repo(db_session, "di-dl-zero")
913 r = await _release(db_session, repo.repo_id, "v1.0.0")
914 asset = await musehub_releases.attach_asset(
915 db_session,
916 release_id=r.release_id,
917 repo_id=repo.repo_id,
918 name="file.zip",
919 download_url="https://cdn.example.com/file.zip",
920 )
921 assert asset.download_count == 0
922
923 async def test_gpg_signature_persisted(
924 self, db_session: AsyncSession
925 ) -> None:
926 from musehub.services import musehub_releases
927 repo = await _repo(db_session, "di-gpg")
928 sig = "-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----"
929 r = await musehub_releases.create_release(
930 db_session, repo_id=repo.repo_id, tag="v1.0.0",
931 commit_id=None, gpg_signature=sig,
932 )
933 assert r.gpg_signature == sig
934
935 async def test_is_draft_persisted(
936 self, db_session: AsyncSession
937 ) -> None:
938 from musehub.services import musehub_releases
939 repo = await _repo(db_session, "di-is-draft")
940 r = await musehub_releases.create_release(
941 db_session, repo_id=repo.repo_id, tag="v1.0.0",
942 commit_id=None, is_draft=True,
943 )
944 assert r.is_draft is True
945
946
947 # ===========================================================================
948 # Layer 6 — Security tests
949 # ===========================================================================
950
951
952 class TestSecurity:
953 async def test_create_release_requires_auth(
954 self, client: AsyncClient, db_session: AsyncSession
955 ) -> None:
956 repo = await _repo(db_session, "sec-create")
957 await db_session.commit()
958 r = await client.post(
959 f"/api/repos/{repo.repo_id}/releases",
960 json={"tag": "v1.0.0"},
961 )
962 assert r.status_code in (401, 403)
963
964 async def test_attach_asset_requires_auth(
965 self, client: AsyncClient, db_session: AsyncSession
966 ) -> None:
967 from musehub.services import musehub_releases
968 repo = await _repo(db_session, "sec-attach")
969 await musehub_releases.create_release(
970 db_session, repo_id=repo.repo_id, tag="v1.0.0", commit_id=None
971 )
972 await db_session.commit()
973 r = await client.post(
974 f"/api/repos/{repo.repo_id}/releases/v1.0.0/assets",
975 json={"name": "f.zip", "downloadUrl": "https://cdn.example.com/f.zip"},
976 )
977 assert r.status_code in (401, 403)
978
979 async def test_delete_asset_requires_auth(
980 self, client: AsyncClient, db_session: AsyncSession
981 ) -> None:
982 from musehub.services import musehub_releases
983 repo = await _repo(db_session, "sec-del-asset")
984 r = await musehub_releases.create_release(
985 db_session, repo_id=repo.repo_id, tag="v1.0.0", commit_id=None
986 )
987 asset = await musehub_releases.attach_asset(
988 db_session, release_id=r.release_id, repo_id=repo.repo_id,
989 name="f.zip", download_url="https://cdn.example.com/f.zip",
990 )
991 await db_session.commit()
992 r2 = await client.delete(
993 f"/api/repos/{repo.repo_id}/releases/v1.0.0/assets/{asset.asset_id}"
994 )
995 assert r2.status_code in (401, 403)
996
997 async def test_private_repo_list_requires_auth(
998 self, client: AsyncClient, db_session: AsyncSession
999 ) -> None:
1000 repo = await _repo(db_session, "sec-private-list", visibility="private")
1001 await db_session.commit()
1002 r = await client.get(f"/api/repos/{repo.repo_id}/releases")
1003 assert r.status_code in (401, 403)
1004
1005 async def test_public_repo_list_no_auth_needed(
1006 self, client: AsyncClient, db_session: AsyncSession
1007 ) -> None:
1008 repo = await _repo(db_session, "sec-public-list", visibility="public")
1009 await db_session.commit()
1010 r = await client.get(f"/api/repos/{repo.repo_id}/releases")
1011 assert r.status_code == 200
1012
1013 async def test_download_tracking_no_auth_for_public(
1014 self, client: AsyncClient, auth_headers: StrDict
1015 ) -> None:
1016 """Anonymous downloads should be tracked (no auth required)."""
1017 repo_id = await _api_repo(client, auth_headers, "sec-anon-dl")
1018 await _api_release(client, auth_headers, repo_id, "v1.0.0")
1019 r = await client.post(
1020 f"/api/repos/{repo_id}/releases/v1.0.0/assets",
1021 json={"name": "f.zip", "downloadUrl": "https://cdn.example.com/f.zip"},
1022 headers=auth_headers,
1023 )
1024 asset_id = r.json()["assetId"]
1025
1026 # No auth headers — anonymous download
1027 r2 = await client.post(
1028 f"/api/repos/{repo_id}/releases/v1.0.0/assets/{asset_id}/download"
1029 )
1030 assert r2.status_code == 204
1031
1032 async def test_unknown_repo_create_returns_404(
1033 self, client: AsyncClient, auth_headers: StrDict
1034 ) -> None:
1035 r = await client.post(
1036 f"/api/repos/{_uid()}/releases",
1037 json={"tag": "v1.0.0"},
1038 headers=auth_headers,
1039 )
1040 assert r.status_code == 404
1041
1042 async def test_create_release_forbidden_for_non_owner(
1043 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1044 ) -> None:
1045 """POST /releases as a non-owner returns 403."""
1046 # "testuser" is the authenticated actor; repo owned by "other-owner".
1047 _other_id = compute_identity_id(b"other-owner")
1048 _now = datetime.now(tz=timezone.utc)
1049 other_repo = MusehubRepo(
1050 repo_id=compute_repo_id(_other_id, "other-release-repo", "code", _now.isoformat()),
1051 name="other-release-repo",
1052 owner="other-owner",
1053 slug="other-release-repo",
1054 visibility="public",
1055 owner_user_id=_other_id,
1056 created_at=_now,
1057 updated_at=_now,
1058 )
1059 db_session.add(other_repo)
1060 await db_session.commit()
1061 r = await client.post(
1062 f"/api/repos/{other_repo.repo_id}/releases",
1063 json={"tag": "v1.0.0"},
1064 headers=auth_headers,
1065 )
1066 assert r.status_code == 403
1067
1068 async def test_attach_asset_forbidden_for_non_owner(
1069 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1070 ) -> None:
1071 """POST /releases/{tag}/assets as a non-owner returns 403."""
1072 from musehub.services import musehub_releases
1073
1074 _other_id2 = compute_identity_id(b"other-owner")
1075 _now2 = datetime.now(tz=timezone.utc)
1076 other_repo = MusehubRepo(
1077 repo_id=compute_repo_id(_other_id2, "other-attach-repo", "code", _now2.isoformat()),
1078 name="other-attach-repo",
1079 owner="other-owner",
1080 slug="other-attach-repo",
1081 visibility="public",
1082 owner_user_id=_other_id2,
1083 created_at=_now2,
1084 updated_at=_now2,
1085 )
1086 db_session.add(other_repo)
1087 await db_session.flush()
1088 await musehub_releases.create_release(
1089 db_session, repo_id=other_repo.repo_id, tag="v1.0.0", commit_id=None
1090 )
1091 await db_session.commit()
1092 r = await client.post(
1093 f"/api/repos/{other_repo.repo_id}/releases/v1.0.0/assets",
1094 json={"name": "f.zip", "downloadUrl": "https://cdn.example.com/f.zip"},
1095 headers=auth_headers,
1096 )
1097 assert r.status_code == 403
1098
1099 async def test_delete_asset_forbidden_for_non_owner(
1100 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1101 ) -> None:
1102 """DELETE /releases/{tag}/assets/{id} as a non-owner returns 403."""
1103 from musehub.services import musehub_releases
1104
1105 _other_id3 = compute_identity_id(b"other-owner")
1106 _now3 = datetime.now(tz=timezone.utc)
1107 other_repo = MusehubRepo(
1108 repo_id=compute_repo_id(_other_id3, "other-del-asset-repo", "code", _now3.isoformat()),
1109 name="other-del-asset-repo",
1110 owner="other-owner",
1111 slug="other-del-asset-repo",
1112 visibility="public",
1113 owner_user_id=_other_id3,
1114 created_at=_now3,
1115 updated_at=_now3,
1116 )
1117 db_session.add(other_repo)
1118 await db_session.flush()
1119 release = await musehub_releases.create_release(
1120 db_session, repo_id=other_repo.repo_id, tag="v1.0.0", commit_id=None
1121 )
1122 asset = await musehub_releases.attach_asset(
1123 db_session,
1124 release_id=release.release_id,
1125 repo_id=other_repo.repo_id,
1126 name="f.zip",
1127 download_url="https://cdn.example.com/f.zip",
1128 )
1129 await db_session.commit()
1130 r = await client.delete(
1131 f"/api/repos/{other_repo.repo_id}/releases/v1.0.0/assets/{asset.asset_id}",
1132 headers=auth_headers,
1133 )
1134 assert r.status_code == 403
1135
1136
1137 # ===========================================================================
1138 # Layer 7 — Performance tests
1139 # ===========================================================================
1140
1141
1142 class TestPerformance:
1143 async def test_list_100_releases_under_500ms(
1144 self, db_session: AsyncSession
1145 ) -> None:
1146 from musehub.services import musehub_releases
1147 repo = await _repo(db_session, "perf-100")
1148
1149 for i in range(100):
1150 await musehub_releases.create_release(
1151 db_session, repo_id=repo.repo_id,
1152 tag=f"v{i}.0.0", commit_id=None,
1153 )
1154
1155 start = time.monotonic()
1156 releases = await musehub_releases.list_releases(db_session, repo.repo_id)
1157 elapsed = time.monotonic() - start
1158
1159 assert releases.total == 100
1160 assert elapsed < 0.5, f"list_releases took {elapsed:.3f}s (limit 0.5s)"
1161
1162 async def test_list_50_assets_under_200ms(
1163 self, db_session: AsyncSession
1164 ) -> None:
1165 from musehub.services import musehub_releases
1166 repo = await _repo(db_session, "perf-50-assets")
1167 r = await _release(db_session, repo.repo_id, "v1.0.0")
1168
1169 for i in range(50):
1170 await musehub_releases.attach_asset(
1171 db_session,
1172 release_id=r.release_id,
1173 repo_id=repo.repo_id,
1174 name=f"asset-{i}.zip",
1175 download_url=f"https://cdn.example.com/asset-{i}.zip",
1176 )
1177
1178 start = time.monotonic()
1179 assets = await musehub_releases.list_release_assets(db_session, r.release_id, "v1.0.0")
1180 elapsed = time.monotonic() - start
1181
1182 assert len(assets.assets) == 50
1183 assert elapsed < 0.2, f"list_release_assets took {elapsed:.3f}s (limit 0.2s)"
1184
1185 def test_parse_changelog_1000x_under_100ms(self) -> None:
1186 raw = '[{"commit_id":"abc","message":"feat: x","sem_ver_bump":"minor","breaking_changes":[],"author":"alice","timestamp":"2025-01-01"}]'
1187 start = time.monotonic()
1188 for _ in range(1000):
1189 _parse_changelog(raw)
1190 elapsed = time.monotonic() - start
1191 assert elapsed < 0.1, f"1000× _parse_changelog took {elapsed:.3f}s (limit 0.1s)"
1192
1193 def test_parse_semantic_report_100x_under_50ms(self) -> None:
1194 report = SemanticReleaseReportResponse(total_files=10, total_symbols=50)
1195 raw = report.model_dump_json()
1196 start = time.monotonic()
1197 for _ in range(100):
1198 _parse_semantic_report(raw)
1199 elapsed = time.monotonic() - start
1200 assert elapsed < 0.05, f"100× _parse_semantic_report took {elapsed:.3f}s (limit 0.05s)"
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago