gabriel / musehub public
test_releases.py python
1,168 lines 43.1 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 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.types.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 async def test_creates_with_all_semver_fields(
307 self, db_session: AsyncSession
308 ) -> None:
309 repo = await _repo(db_session, "semver-fields")
310 r = await _release(
311 db_session, repo.repo_id, "v3.2.1",
312 semver_major=3, semver_minor=2, semver_patch=1,
313 )
314 assert r.semver_major == 3
315 assert r.semver_minor == 2
316 assert r.semver_patch == 1
317 assert r.tag == "v3.2.1"
318
319 async def test_duplicate_tag_raises_value_error(
320 self, db_session: AsyncSession
321 ) -> None:
322 repo = await _repo(db_session, "dup-tag")
323 await _release(db_session, repo.repo_id, "v1.0.0")
324 with pytest.raises(ValueError, match="already exists"):
325 await _release(db_session, repo.repo_id, "v1.0.0")
326
327 async def test_invalid_channel_raises_value_error(
328 self, db_session: AsyncSession
329 ) -> None:
330 from musehub.services import musehub_releases
331 repo = await _repo(db_session, "bad-channel")
332 with pytest.raises(ValueError, match="Unknown channel"):
333 await musehub_releases.create_release(
334 db_session, repo_id=repo.repo_id,
335 tag="v1.0.0", commit_id=None, channel="underground",
336 )
337
338 async def test_valid_channels_accepted(
339 self, db_session: AsyncSession
340 ) -> None:
341 for i, channel in enumerate(["stable", "beta", "alpha", "nightly"]):
342 repo = await _repo(db_session, f"chan-{channel}")
343 r = await _release(db_session, repo.repo_id, "v1.0.0", channel=channel)
344 assert r.channel == channel
345
346 async def test_semantic_report_stored_and_parsed(
347 self, db_session: AsyncSession
348 ) -> None:
349 from musehub.services import musehub_releases
350 repo = await _repo(db_session, "semreport")
351 report = SemanticReleaseReportResponse(
352 total_files=5, total_symbols=20, agent_commits=2
353 )
354 r = await musehub_releases.create_release(
355 db_session, repo_id=repo.repo_id,
356 tag="v1.0.0", commit_id=None,
357 semantic_report_json=report.model_dump_json(),
358 )
359 assert r.semantic_report is not None
360 assert r.semantic_report.total_files == 5
361 assert r.semantic_report.agent_commits == 2
362
363 async def test_changelog_stored_and_parsed(
364 self, db_session: AsyncSession
365 ) -> None:
366 from musehub.services import musehub_releases
367 repo = await _repo(db_session, "changelog-stored")
368 entry = ChangelogEntryResponse(
369 commit_id="abc123", message="feat: new thing", sem_ver_bump="minor"
370 )
371 r = await musehub_releases.create_release(
372 db_session, repo_id=repo.repo_id,
373 tag="v1.0.0", commit_id=None, changelog=[entry],
374 )
375 assert len(r.changelog) == 1
376 assert r.changelog[0].commit_id == "abc123"
377
378
379 class TestIntegrationListReleases:
380 async def test_newest_first_ordering(
381 self, db_session: AsyncSession
382 ) -> None:
383 from musehub.services import musehub_releases
384 repo = await _repo(db_session, "order-test")
385 await _release(db_session, repo.repo_id, "v1.0.0")
386 await _release(db_session, repo.repo_id, "v2.0.0")
387 await _release(db_session, repo.repo_id, "v3.0.0")
388
389 releases = await musehub_releases.list_releases(db_session, repo.repo_id)
390 assert releases.releases[0].tag == "v3.0.0"
391 assert releases.releases[-1].tag == "v1.0.0"
392
393 async def test_drafts_excluded_by_default(
394 self, db_session: AsyncSession
395 ) -> None:
396 from musehub.services import musehub_releases
397 repo = await _repo(db_session, "draft-excl")
398 await _release(db_session, repo.repo_id, "v1.0.0")
399 await _release(db_session, repo.repo_id, "v2.0.0-draft", is_draft=True)
400
401 releases = await musehub_releases.list_releases(db_session, repo.repo_id)
402 assert releases.total == 1
403 assert releases.releases[0].tag == "v1.0.0"
404
405 async def test_drafts_included_when_requested(
406 self, db_session: AsyncSession
407 ) -> None:
408 from musehub.services import musehub_releases
409 repo = await _repo(db_session, "draft-incl")
410 await _release(db_session, repo.repo_id, "v1.0.0")
411 await _release(db_session, repo.repo_id, "v2.0.0-draft", is_draft=True)
412
413 releases = await musehub_releases.list_releases(
414 db_session, repo.repo_id, include_drafts=True
415 )
416 assert releases.total == 2
417
418 async def test_channel_filter(
419 self, db_session: AsyncSession
420 ) -> None:
421 from musehub.services import musehub_releases
422 repo = await _repo(db_session, "chan-filter")
423 await _release(db_session, repo.repo_id, "v1.0.0", channel="stable")
424 await _release(db_session, repo.repo_id, "v2.0.0-beta.1", channel="beta")
425
426 betas = await musehub_releases.list_releases(
427 db_session, repo.repo_id, channel="beta"
428 )
429 assert betas.total == 1
430 assert betas.releases[0].channel == "beta"
431
432
433 class TestIntegrationGetByTag:
434 async def test_found_by_tag(self, db_session: AsyncSession) -> None:
435 from musehub.services import musehub_releases
436 repo = await _repo(db_session, "get-by-tag")
437 await _release(db_session, repo.repo_id, "v5.0.0")
438 result = await musehub_releases.get_release_by_tag(db_session, repo.repo_id, "v5.0.0")
439 assert result is not None
440 assert result.tag == "v5.0.0"
441
442 async def test_not_found_returns_none(self, db_session: AsyncSession) -> None:
443 from musehub.services import musehub_releases
444 repo = await _repo(db_session, "get-by-tag-miss")
445 result = await musehub_releases.get_release_by_tag(db_session, repo.repo_id, "v99.0.0")
446 assert result is None
447
448
449 class TestIntegrationDeleteByTag:
450 async def test_delete_returns_true_when_found(
451 self, db_session: AsyncSession
452 ) -> None:
453 from musehub.services import musehub_releases
454 repo = await _repo(db_session, "del-true")
455 await _release(db_session, repo.repo_id, "v1.0.0")
456 result = await musehub_releases.delete_release_by_tag(db_session, repo.repo_id, "v1.0.0")
457 assert result is True
458
459 async def test_delete_returns_false_when_not_found(
460 self, db_session: AsyncSession
461 ) -> None:
462 from musehub.services import musehub_releases
463 repo = await _repo(db_session, "del-false")
464 result = await musehub_releases.delete_release_by_tag(db_session, repo.repo_id, "v9.9.9")
465 assert result is False
466
467 async def test_delete_removes_from_list(
468 self, db_session: AsyncSession
469 ) -> None:
470 from musehub.services import musehub_releases
471 repo = await _repo(db_session, "del-list")
472 await _release(db_session, repo.repo_id, "v1.0.0")
473 await musehub_releases.delete_release_by_tag(db_session, repo.repo_id, "v1.0.0")
474 releases = await musehub_releases.list_releases(db_session, repo.repo_id)
475 assert releases.total == 0
476
477
478 class TestIntegrationAssets:
479 async def test_attach_and_list_asset(
480 self, db_session: AsyncSession
481 ) -> None:
482 from musehub.services import musehub_releases
483 repo = await _repo(db_session, "asset-attach")
484 r = await _release(db_session, repo.repo_id, "v1.0.0")
485
486 asset = await musehub_releases.attach_asset(
487 db_session,
488 release_id=r.release_id,
489 repo_id=repo.repo_id,
490 name="bundle.zip",
491 label="Bundle",
492 content_type="application/zip",
493 size=1024,
494 download_url="https://cdn.example.com/bundle.zip",
495 )
496 assert asset.name == "bundle.zip"
497 assert asset.size == 1024
498 assert asset.download_count == 0
499
500 assets = await musehub_releases.list_release_assets(
501 db_session, r.release_id, "v1.0.0"
502 )
503 assert assets.release_id == r.release_id
504 assert len(assets.assets) == 1
505
506 async def test_download_count_increment(
507 self, db_session: AsyncSession
508 ) -> None:
509 from musehub.services import musehub_releases
510 repo = await _repo(db_session, "dl-count")
511 r = await _release(db_session, repo.repo_id, "v1.0.0")
512 asset = await musehub_releases.attach_asset(
513 db_session,
514 release_id=r.release_id,
515 repo_id=repo.repo_id,
516 name="file.zip",
517 download_url="https://cdn.example.com/file.zip",
518 )
519
520 await musehub_releases.increment_asset_download_count(db_session, asset.asset_id)
521 await musehub_releases.increment_asset_download_count(db_session, asset.asset_id)
522 await db_session.flush()
523
524 stats = await musehub_releases.get_download_stats(
525 db_session, r.release_id, r.tag
526 )
527 assert stats.total_downloads == 2
528 assert stats.assets[0].download_count == 2
529
530 async def test_remove_asset_returns_true(
531 self, db_session: AsyncSession
532 ) -> None:
533 from musehub.services import musehub_releases
534 repo = await _repo(db_session, "asset-remove")
535 r = await _release(db_session, repo.repo_id, "v1.0.0")
536 asset = await musehub_releases.attach_asset(
537 db_session,
538 release_id=r.release_id,
539 repo_id=repo.repo_id,
540 name="to-remove.zip",
541 download_url="https://cdn.example.com/rm.zip",
542 )
543 result = await musehub_releases.remove_asset(db_session, asset.asset_id)
544 assert result is True
545
546 async def test_remove_nonexistent_asset_returns_false(
547 self, db_session: AsyncSession
548 ) -> None:
549 from musehub.services import musehub_releases
550 result = await musehub_releases.remove_asset(db_session, _uid())
551 assert result is False
552
553 async def test_increment_nonexistent_asset_returns_false(
554 self, db_session: AsyncSession
555 ) -> None:
556 from musehub.services import musehub_releases
557 result = await musehub_releases.increment_asset_download_count(db_session, _uid())
558 assert result is False
559
560
561 # ===========================================================================
562 # Layer 3 — E2E tests
563 # ===========================================================================
564
565
566 class TestE2EReleaseLifecycle:
567 async def test_create_get_list(
568 self, client: AsyncClient, auth_headers: StrDict
569 ) -> None:
570 repo_id = await _api_repo(client, auth_headers, "e2e-lifecycle")
571 r = await _api_release(client, auth_headers, repo_id, "v1.0.0",
572 title="First Release", body="Initial release notes")
573 assert r["tag"] == "v1.0.0"
574 assert r["title"] == "First Release"
575 assert r["channel"] == "stable"
576 assert r["isPrerelease"] is False
577 release_id = r["releaseId"]
578
579 # GET by tag
580 r2 = await client.get(f"/api/repos/{repo_id}/releases/v1.0.0")
581 assert r2.status_code == 200
582 assert r2.json()["releaseId"] == release_id
583
584 # LIST
585 r3 = await client.get(f"/api/repos/{repo_id}/releases")
586 assert r3.status_code == 200
587 releases = r3.json()["releases"]
588 assert len(releases) == 1
589 assert releases[0]["tag"] == "v1.0.0"
590
591 async def test_attach_asset_and_download_stats(
592 self, client: AsyncClient, auth_headers: StrDict
593 ) -> None:
594 repo_id = await _api_repo(client, auth_headers, "e2e-assets")
595 await _api_release(client, auth_headers, repo_id, "v1.0.0")
596
597 # Attach asset
598 r = await client.post(
599 f"/api/repos/{repo_id}/releases/v1.0.0/assets",
600 json={
601 "name": "bundle.zip",
602 "label": "Main Bundle",
603 "contentType": "application/zip",
604 "size": 2048,
605 "downloadUrl": "https://cdn.example.com/bundle.zip",
606 },
607 headers=auth_headers,
608 )
609 assert r.status_code == 201
610 asset_id = r.json()["assetId"]
611 assert r.json()["name"] == "bundle.zip"
612 assert r.json()["downloadCount"] == 0
613
614 # Record download
615 r2 = await client.post(
616 f"/api/repos/{repo_id}/releases/v1.0.0/assets/{asset_id}/download"
617 )
618 assert r2.status_code == 204
619
620 # Get download stats
621 r3 = await client.get(
622 f"/api/repos/{repo_id}/releases/v1.0.0/downloads"
623 )
624 assert r3.status_code == 200
625 data = r3.json()
626 assert data["totalDownloads"] == 1
627 assert data["assets"][0]["downloadCount"] == 1
628
629 async def test_delete_asset(
630 self, client: AsyncClient, auth_headers: StrDict
631 ) -> None:
632 repo_id = await _api_repo(client, auth_headers, "e2e-del-asset")
633 await _api_release(client, auth_headers, repo_id, "v1.0.0")
634
635 r = await client.post(
636 f"/api/repos/{repo_id}/releases/v1.0.0/assets",
637 json={"name": "file.zip", "downloadUrl": "https://cdn.example.com/f.zip"},
638 headers=auth_headers,
639 )
640 asset_id = r.json()["assetId"]
641
642 r2 = await client.delete(
643 f"/api/repos/{repo_id}/releases/v1.0.0/assets/{asset_id}",
644 headers=auth_headers,
645 )
646 assert r2.status_code == 204
647
648 # Asset list should be empty
649 r3 = await client.get(f"/api/repos/{repo_id}/releases/v1.0.0/assets")
650 assert r3.status_code == 200
651 assert r3.json()["assets"] == []
652
653
654 class TestE2EDuplicateTag:
655 async def test_second_create_same_tag_returns_409(
656 self, client: AsyncClient, auth_headers: StrDict
657 ) -> None:
658 repo_id = await _api_repo(client, auth_headers, "e2e-dup-tag")
659 await _api_release(client, auth_headers, repo_id, "v1.0.0")
660
661 r = await client.post(
662 f"/api/repos/{repo_id}/releases",
663 json={"tag": "v1.0.0"},
664 headers=auth_headers,
665 )
666 assert r.status_code == 409
667
668
669 class TestE2EDraftRelease:
670 async def test_draft_excluded_from_list(
671 self, client: AsyncClient, auth_headers: StrDict
672 ) -> None:
673 repo_id = await _api_repo(client, auth_headers, "e2e-draft")
674 await _api_release(client, auth_headers, repo_id, "v1.0.0")
675 await _api_release(client, auth_headers, repo_id, "v2.0.0", isDraft=True)
676
677 r = await client.get(f"/api/repos/{repo_id}/releases")
678 assert r.status_code == 200
679 tags = [rel["tag"] for rel in r.json()["releases"]]
680 assert "v1.0.0" in tags
681 assert "v2.0.0" not in tags
682
683 async def test_draft_accessible_by_tag(
684 self, client: AsyncClient, auth_headers: StrDict
685 ) -> None:
686 repo_id = await _api_repo(client, auth_headers, "e2e-draft-get")
687 await _api_release(client, auth_headers, repo_id, "v1.0.0-draft", isDraft=True)
688
689 r = await client.get(f"/api/repos/{repo_id}/releases/v1.0.0-draft")
690 assert r.status_code == 200
691 assert r.json()["isDraft"] is True
692
693 async def test_beta_channel_is_prerelease_in_response(
694 self, client: AsyncClient, auth_headers: StrDict
695 ) -> None:
696 repo_id = await _api_repo(client, auth_headers, "e2e-beta")
697 r = await _api_release(client, auth_headers, repo_id, "v2.0.0-beta.1", channel="beta")
698 assert r["channel"] == "beta"
699 assert r["isPrerelease"] is True
700
701
702 class TestE2ECreateFromDict:
703 async def test_create_from_dict_minimal(
704 self, db_session: AsyncSession
705 ) -> None:
706 from musehub.services import musehub_releases
707 repo = await _repo(db_session, "from-dict-min")
708 r = await musehub_releases.create_release_from_dict(
709 db_session, repo.repo_id,
710 {"tag": "v1.0.0", "title": "CLI Release"},
711 )
712 assert r.tag == "v1.0.0"
713 assert r.title == "CLI Release"
714
715 async def test_create_from_dict_missing_tag_raises(
716 self, db_session: AsyncSession
717 ) -> None:
718 from musehub.services import musehub_releases
719 repo = await _repo(db_session, "from-dict-notag")
720 with pytest.raises(ValueError, match="missing required field 'tag'"):
721 await musehub_releases.create_release_from_dict(
722 db_session, repo.repo_id, {"title": "No tag"}
723 )
724
725 async def test_create_from_dict_full_payload(
726 self, db_session: AsyncSession
727 ) -> None:
728 from musehub.services import musehub_releases
729 repo = await _repo(db_session, "from-dict-full")
730 data = {
731 "tag": "v3.1.4",
732 "title": "Full Release",
733 "body": "Release notes here",
734 "channel": "beta",
735 "agent_id": "agent-1",
736 "model_id": "claude-sonnet-4-6",
737 "is_draft": False,
738 }
739 r = await musehub_releases.create_release_from_dict(db_session, repo.repo_id, data)
740 assert r.tag == "v3.1.4"
741 assert r.channel == "beta"
742 assert r.agent_id == "agent-1"
743
744
745 class TestE2ETags:
746 async def test_list_tags_returns_all_releases_as_tags(
747 self, client: AsyncClient, auth_headers: StrDict
748 ) -> None:
749 repo_id = await _api_repo(client, auth_headers, "e2e-tags")
750 await _api_release(client, auth_headers, repo_id, "v1.0.0")
751 await _api_release(client, auth_headers, repo_id, "v2.0.0")
752
753 r = await client.get(f"/api/repos/{repo_id}/tags")
754 assert r.status_code == 200
755 tags = [t["tag"] for t in r.json()["tags"]]
756 assert "v1.0.0" in tags
757 assert "v2.0.0" in tags
758
759 async def test_tag_namespace_version_for_semver(
760 self, client: AsyncClient, auth_headers: StrDict
761 ) -> None:
762 repo_id = await _api_repo(client, auth_headers, "e2e-ns-ver")
763 await _api_release(client, auth_headers, repo_id, "v1.0.0")
764
765 r = await client.get(f"/api/repos/{repo_id}/tags")
766 assert r.status_code == 200
767 tag_obj = next(t for t in r.json()["tags"] if t["tag"] == "v1.0.0")
768 assert tag_obj["namespace"] == "version"
769
770 async def test_unknown_tag_returns_404(
771 self, client: AsyncClient, auth_headers: StrDict
772 ) -> None:
773 repo_id = await _api_repo(client, auth_headers, "e2e-tag-404")
774 r = await client.get(f"/api/repos/{repo_id}/releases/v99.0.0")
775 assert r.status_code == 404
776
777
778 # ===========================================================================
779 # Layer 4 — Stress tests
780 # ===========================================================================
781
782
783 class TestStress:
784 async def test_30_releases_sequential(
785 self, db_session: AsyncSession
786 ) -> None:
787 from musehub.services import musehub_releases
788 repo = await _repo(db_session, "stress-30")
789
790 for i in range(30):
791 await musehub_releases.create_release(
792 db_session,
793 repo_id=repo.repo_id,
794 tag=f"v{i}.0.0",
795 commit_id=None,
796 )
797
798 releases = await musehub_releases.list_releases(db_session, repo.repo_id)
799 assert releases.total == 30
800
801 async def test_20_assets_on_one_release(
802 self, db_session: AsyncSession
803 ) -> None:
804 from musehub.services import musehub_releases
805 repo = await _repo(db_session, "stress-assets")
806 r = await _release(db_session, repo.repo_id, "v1.0.0")
807
808 for i in range(20):
809 await musehub_releases.attach_asset(
810 db_session,
811 release_id=r.release_id,
812 repo_id=repo.repo_id,
813 name=f"asset-{i}.zip",
814 download_url=f"https://cdn.example.com/asset-{i}.zip",
815 )
816
817 assets = await musehub_releases.list_release_assets(db_session, r.release_id, "v1.0.0")
818 assert len(assets.assets) == 20
819
820
821 # ===========================================================================
822 # Layer 5 — Data Integrity tests
823 # ===========================================================================
824
825
826 class TestDataIntegrity:
827 async def test_tag_unique_per_repo_not_cross_repo(
828 self, db_session: AsyncSession
829 ) -> None:
830 from musehub.services import musehub_releases
831 r1 = await _repo(db_session, "di-r1")
832 r2 = await _repo(db_session, "di-r2")
833
834 await _release(db_session, r1.repo_id, "v1.0.0")
835 # Same tag in different repo is fine
836 r = await musehub_releases.create_release(
837 db_session, repo_id=r2.repo_id, tag="v1.0.0", commit_id=None
838 )
839 assert r.tag == "v1.0.0"
840
841 async def test_semver_fields_persisted_correctly(
842 self, db_session: AsyncSession
843 ) -> None:
844 from sqlalchemy import select as sa_select
845 from musehub.db.musehub_models import MusehubRelease
846 from musehub.services import musehub_releases
847
848 repo = await _repo(db_session, "di-semver")
849 r = await musehub_releases.create_release(
850 db_session, repo_id=repo.repo_id, tag="v4.5.6",
851 commit_id=None,
852 semver_major=4, semver_minor=5, semver_patch=6,
853 semver_pre="rc.1", semver_build="20250101",
854 )
855
856 stmt = sa_select(MusehubRelease).where(MusehubRelease.release_id == r.release_id)
857 row = (await db_session.execute(stmt)).scalar_one()
858 assert row.semver_major == 4
859 assert row.semver_minor == 5
860 assert row.semver_patch == 6
861 assert row.semver_pre == "rc.1"
862 assert row.semver_build == "20250101"
863
864 async def test_asset_cascade_deleted_with_release(
865 self, db_session: AsyncSession
866 ) -> None:
867 from sqlalchemy import select as sa_select
868 from musehub.db.musehub_models import MusehubReleaseAsset
869 from musehub.services import musehub_releases
870
871 repo = await _repo(db_session, "di-cascade")
872 r = await _release(db_session, repo.repo_id, "v1.0.0")
873 await musehub_releases.attach_asset(
874 db_session,
875 release_id=r.release_id,
876 repo_id=repo.repo_id,
877 name="file.zip",
878 download_url="https://cdn.example.com/file.zip",
879 )
880 await db_session.commit()
881
882 await musehub_releases.delete_release_by_tag(db_session, repo.repo_id, "v1.0.0")
883 await db_session.commit()
884
885 stmt = sa_select(MusehubReleaseAsset).where(
886 MusehubReleaseAsset.release_id == r.release_id
887 )
888 assets = (await db_session.execute(stmt)).scalars().all()
889 assert len(assets) == 0
890
891 async def test_download_count_starts_at_zero(
892 self, db_session: AsyncSession
893 ) -> None:
894 from musehub.services import musehub_releases
895 repo = await _repo(db_session, "di-dl-zero")
896 r = await _release(db_session, repo.repo_id, "v1.0.0")
897 asset = await musehub_releases.attach_asset(
898 db_session,
899 release_id=r.release_id,
900 repo_id=repo.repo_id,
901 name="file.zip",
902 download_url="https://cdn.example.com/file.zip",
903 )
904 assert asset.download_count == 0
905
906 async def test_gpg_signature_persisted(
907 self, db_session: AsyncSession
908 ) -> None:
909 from musehub.services import musehub_releases
910 repo = await _repo(db_session, "di-gpg")
911 sig = "-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----"
912 r = await musehub_releases.create_release(
913 db_session, repo_id=repo.repo_id, tag="v1.0.0",
914 commit_id=None, gpg_signature=sig,
915 )
916 assert r.gpg_signature == sig
917
918 async def test_is_draft_persisted(
919 self, db_session: AsyncSession
920 ) -> None:
921 from musehub.services import musehub_releases
922 repo = await _repo(db_session, "di-is-draft")
923 r = await musehub_releases.create_release(
924 db_session, repo_id=repo.repo_id, tag="v1.0.0",
925 commit_id=None, is_draft=True,
926 )
927 assert r.is_draft is True
928
929
930 # ===========================================================================
931 # Layer 6 — Security tests
932 # ===========================================================================
933
934
935 class TestSecurity:
936 async def test_create_release_requires_auth(
937 self, client: AsyncClient, db_session: AsyncSession
938 ) -> None:
939 repo = await _repo(db_session, "sec-create")
940 await db_session.commit()
941 r = await client.post(
942 f"/api/repos/{repo.repo_id}/releases",
943 json={"tag": "v1.0.0"},
944 )
945 assert r.status_code in (401, 403)
946
947 async def test_attach_asset_requires_auth(
948 self, client: AsyncClient, db_session: AsyncSession
949 ) -> None:
950 from musehub.services import musehub_releases
951 repo = await _repo(db_session, "sec-attach")
952 await musehub_releases.create_release(
953 db_session, repo_id=repo.repo_id, tag="v1.0.0", commit_id=None
954 )
955 await db_session.commit()
956 r = await client.post(
957 f"/api/repos/{repo.repo_id}/releases/v1.0.0/assets",
958 json={"name": "f.zip", "downloadUrl": "https://cdn.example.com/f.zip"},
959 )
960 assert r.status_code in (401, 403)
961
962 async def test_delete_asset_requires_auth(
963 self, client: AsyncClient, db_session: AsyncSession
964 ) -> None:
965 from musehub.services import musehub_releases
966 repo = await _repo(db_session, "sec-del-asset")
967 r = await musehub_releases.create_release(
968 db_session, repo_id=repo.repo_id, tag="v1.0.0", commit_id=None
969 )
970 asset = await musehub_releases.attach_asset(
971 db_session, release_id=r.release_id, repo_id=repo.repo_id,
972 name="f.zip", download_url="https://cdn.example.com/f.zip",
973 )
974 await db_session.commit()
975 r2 = await client.delete(
976 f"/api/repos/{repo.repo_id}/releases/v1.0.0/assets/{asset.asset_id}"
977 )
978 assert r2.status_code in (401, 403)
979
980 async def test_private_repo_list_requires_auth(
981 self, client: AsyncClient, db_session: AsyncSession
982 ) -> None:
983 repo = await _repo(db_session, "sec-private-list", visibility="private")
984 await db_session.commit()
985 r = await client.get(f"/api/repos/{repo.repo_id}/releases")
986 assert r.status_code in (401, 403)
987
988 async def test_public_repo_list_no_auth_needed(
989 self, client: AsyncClient, db_session: AsyncSession
990 ) -> None:
991 repo = await _repo(db_session, "sec-public-list", visibility="public")
992 await db_session.commit()
993 r = await client.get(f"/api/repos/{repo.repo_id}/releases")
994 assert r.status_code == 200
995
996 async def test_download_tracking_no_auth_for_public(
997 self, client: AsyncClient, auth_headers: StrDict
998 ) -> None:
999 """Anonymous downloads should be tracked (no auth required)."""
1000 repo_id = await _api_repo(client, auth_headers, "sec-anon-dl")
1001 await _api_release(client, auth_headers, repo_id, "v1.0.0")
1002 r = await client.post(
1003 f"/api/repos/{repo_id}/releases/v1.0.0/assets",
1004 json={"name": "f.zip", "downloadUrl": "https://cdn.example.com/f.zip"},
1005 headers=auth_headers,
1006 )
1007 asset_id = r.json()["assetId"]
1008
1009 # No auth headers — anonymous download
1010 r2 = await client.post(
1011 f"/api/repos/{repo_id}/releases/v1.0.0/assets/{asset_id}/download"
1012 )
1013 assert r2.status_code == 204
1014
1015 async def test_unknown_repo_create_returns_404(
1016 self, client: AsyncClient, auth_headers: StrDict
1017 ) -> None:
1018 r = await client.post(
1019 f"/api/repos/{_uid()}/releases",
1020 json={"tag": "v1.0.0"},
1021 headers=auth_headers,
1022 )
1023 assert r.status_code == 404
1024
1025 async def test_create_release_forbidden_for_non_owner(
1026 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1027 ) -> None:
1028 """POST /releases as a non-owner returns 403."""
1029 # "testuser" is the authenticated actor; repo owned by "other-owner".
1030 other_repo = MusehubRepo(
1031 name="other-release-repo",
1032 owner="other-owner",
1033 slug="other-release-repo",
1034 visibility="public",
1035 owner_user_id="uid-other",
1036 )
1037 db_session.add(other_repo)
1038 await db_session.commit()
1039 r = await client.post(
1040 f"/api/repos/{other_repo.repo_id}/releases",
1041 json={"tag": "v1.0.0"},
1042 headers=auth_headers,
1043 )
1044 assert r.status_code == 403
1045
1046 async def test_attach_asset_forbidden_for_non_owner(
1047 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1048 ) -> None:
1049 """POST /releases/{tag}/assets as a non-owner returns 403."""
1050 from musehub.services import musehub_releases
1051
1052 other_repo = MusehubRepo(
1053 name="other-attach-repo",
1054 owner="other-owner",
1055 slug="other-attach-repo",
1056 visibility="public",
1057 owner_user_id="uid-other",
1058 )
1059 db_session.add(other_repo)
1060 await db_session.flush()
1061 await musehub_releases.create_release(
1062 db_session, repo_id=other_repo.repo_id, tag="v1.0.0", commit_id=None
1063 )
1064 await db_session.commit()
1065 r = await client.post(
1066 f"/api/repos/{other_repo.repo_id}/releases/v1.0.0/assets",
1067 json={"name": "f.zip", "downloadUrl": "https://cdn.example.com/f.zip"},
1068 headers=auth_headers,
1069 )
1070 assert r.status_code == 403
1071
1072 async def test_delete_asset_forbidden_for_non_owner(
1073 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1074 ) -> None:
1075 """DELETE /releases/{tag}/assets/{id} as a non-owner returns 403."""
1076 from musehub.services import musehub_releases
1077
1078 other_repo = MusehubRepo(
1079 name="other-del-asset-repo",
1080 owner="other-owner",
1081 slug="other-del-asset-repo",
1082 visibility="public",
1083 owner_user_id="uid-other",
1084 )
1085 db_session.add(other_repo)
1086 await db_session.flush()
1087 release = await musehub_releases.create_release(
1088 db_session, repo_id=other_repo.repo_id, tag="v1.0.0", commit_id=None
1089 )
1090 asset = await musehub_releases.attach_asset(
1091 db_session,
1092 release_id=release.release_id,
1093 repo_id=other_repo.repo_id,
1094 name="f.zip",
1095 download_url="https://cdn.example.com/f.zip",
1096 )
1097 await db_session.commit()
1098 r = await client.delete(
1099 f"/api/repos/{other_repo.repo_id}/releases/v1.0.0/assets/{asset.asset_id}",
1100 headers=auth_headers,
1101 )
1102 assert r.status_code == 403
1103
1104
1105 # ===========================================================================
1106 # Layer 7 — Performance tests
1107 # ===========================================================================
1108
1109
1110 class TestPerformance:
1111 async def test_list_100_releases_under_500ms(
1112 self, db_session: AsyncSession
1113 ) -> None:
1114 from musehub.services import musehub_releases
1115 repo = await _repo(db_session, "perf-100")
1116
1117 for i in range(100):
1118 await musehub_releases.create_release(
1119 db_session, repo_id=repo.repo_id,
1120 tag=f"v{i}.0.0", commit_id=None,
1121 )
1122
1123 start = time.monotonic()
1124 releases = await musehub_releases.list_releases(db_session, repo.repo_id)
1125 elapsed = time.monotonic() - start
1126
1127 assert releases.total == 100
1128 assert elapsed < 0.5, f"list_releases took {elapsed:.3f}s (limit 0.5s)"
1129
1130 async def test_list_50_assets_under_200ms(
1131 self, db_session: AsyncSession
1132 ) -> None:
1133 from musehub.services import musehub_releases
1134 repo = await _repo(db_session, "perf-50-assets")
1135 r = await _release(db_session, repo.repo_id, "v1.0.0")
1136
1137 for i in range(50):
1138 await musehub_releases.attach_asset(
1139 db_session,
1140 release_id=r.release_id,
1141 repo_id=repo.repo_id,
1142 name=f"asset-{i}.zip",
1143 download_url=f"https://cdn.example.com/asset-{i}.zip",
1144 )
1145
1146 start = time.monotonic()
1147 assets = await musehub_releases.list_release_assets(db_session, r.release_id, "v1.0.0")
1148 elapsed = time.monotonic() - start
1149
1150 assert len(assets.assets) == 50
1151 assert elapsed < 0.2, f"list_release_assets took {elapsed:.3f}s (limit 0.2s)"
1152
1153 def test_parse_changelog_1000x_under_100ms(self) -> None:
1154 raw = '[{"commit_id":"abc","message":"feat: x","sem_ver_bump":"minor","breaking_changes":[],"author":"alice","timestamp":"2025-01-01"}]'
1155 start = time.monotonic()
1156 for _ in range(1000):
1157 _parse_changelog(raw)
1158 elapsed = time.monotonic() - start
1159 assert elapsed < 0.1, f"1000× _parse_changelog took {elapsed:.3f}s (limit 0.1s)"
1160
1161 def test_parse_semantic_report_100x_under_50ms(self) -> None:
1162 report = SemanticReleaseReportResponse(total_files=10, total_symbols=50)
1163 raw = report.model_dump_json()
1164 start = time.monotonic()
1165 for _ in range(100):
1166 _parse_semantic_report(raw)
1167 elapsed = time.monotonic() - start
1168 assert elapsed < 0.05, f"100× _parse_semantic_report took {elapsed:.3f}s (limit 0.05s)"
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago