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