gabriel / musehub public
test_clones_integration.py python
337 lines 13.3 KB
Raw
sha256:4992098130166d191cefed0a2821d19cd3cdd3cf50867a4e715c2b30636826c7 fix: repair syntax errors from typing annotation cleanup Sonnet 4.6 20 days ago
1 """Tier 2 — Integration tests for the clone browser UI routes (issue #17).
2
3 Exercises ``intel_clones_page`` and ``intel_clones_detail_page`` against a
4 real PostgreSQL test database. All 15 cases use the ``client`` + ``db_session``
5 fixtures from conftest so tests run against the full ASGI stack with live SQL.
6
7 Cases:
8 I01 List page returns 200 when repo has 5 clusters
9 I02 List page returns 200 + empty state when no clusters
10 I03 Tier filter ``exact`` returns only exact clusters
11 I04 Tier filter ``near`` returns only near clusters
12 I05 Invalid tier coerces to all — 200, no 400
13 I06 ``top=50`` activates the 50 pill
14 I07 ``top=9999`` clamps to default (20) — 200
15 I08 Detail page returns 200 for known cluster_hash
16 I09 Detail page returns 200 + empty state for unknown hash
17 I10 Detail page returns 200 + empty state when cluster param absent
18 I11 Dashboard returns 200 with clones card when rows exist
19 I12 Dashboard returns 200 with empty card when no rows
20 I13 Detail page members grouped by file → files_breakdown present
21 I14 Cross-file cluster → ``cl-cross-file`` badge in response
22 I15 Same-file cluster → ``cl-cross-file`` absent from response
23 """
24 from __future__ import annotations
25
26 import json
27
28 import pytest
29 import pytest_asyncio
30 import sqlalchemy as sa
31 from httpx import AsyncClient
32 from sqlalchemy.dialects.postgresql import insert as pg_insert
33 from sqlalchemy.ext.asyncio import AsyncSession
34
35 from musehub.db.musehub_intel_models import MusehubIntelClones
36 from musehub.db.musehub_repo_models import MusehubRepo
37 from tests.factories import create_repo
38 from muse.core.types import long_id
39
40 # ─────────────────────────────────────────────────────────────────────────────
41 # Seed helpers
42 # ─────────────────────────────────────────────────────────────────────────────
43
44 _REF = long_id("a" * 64)
45
46
47 def _members(
48 file_a: str = "src/a.py",
49 file_b: str | None = "src/b.py",
50 n: int = 2,
51 kind: str = "function",
52 language: str = "Python",
53 ) -> str:
54 """Build a members_json blob for test fixture rows."""
55 members = []
56 for i in range(n):
57 file = file_a if (file_b is None or i % 2 == 0) else file_b
58 members.append(
59 {
60 "address": f"{file}::fn_{i}",
61 "kind": kind,
62 "language": language,
63 "body_hash": long_id("b" * 64),
64 "signature_id": long_id("c" * 64),
65 "content_id": long_id("d" * 64),
66 }
67 )
68 return json.dumps(members)
69
70
71 def _same_file_members(n: int = 2) -> str:
72 """All members in one file — not cross-file."""
73 return _members(file_a="src/a.py", file_b=None, n=n)
74
75
76 async def _insert_cluster(
77 session: AsyncSession,
78 repo_id: str,
79 *,
80 cluster_hash: str,
81 tier: str = "exact",
82 member_count: int = 2,
83 members_json: str | None = None,
84 ) -> None:
85 """Upsert a single MusehubIntelClones row."""
86 if members_json is None:
87 members_json = _members(n=member_count)
88 await session.execute(
89 pg_insert(MusehubIntelClones)
90 .values(
91 repo_id=repo_id,
92 cluster_hash=cluster_hash,
93 tier=tier,
94 member_count=member_count,
95 members_json=members_json,
96 ref=_REF,
97 )
98 .on_conflict_do_update(
99 index_elements=["repo_id", "cluster_hash"],
100 set_={"tier": tier, "member_count": member_count, "members_json": members_json},
101 )
102 )
103 await session.commit()
104
105
106 # ─────────────────────────────────────────────────────────────────────────────
107 # Fixtures
108 # ─────────────────────────────────────────────────────────────────────────────
109
110 @pytest_asyncio.fixture
111 async def repo(db_session: AsyncSession) -> MusehubRepo:
112 """A public repo owned by testclones for all integration tests."""
113 return await create_repo(db_session, owner="testclones", slug="clone-browser")
114
115
116 @pytest_asyncio.fixture
117 async def repo_with_clusters(db_session: AsyncSession, repo: MusehubRepo) -> MusehubRepo:
118 """Seed 3 exact + 2 near clusters for the list-page tests."""
119 for i in range(3):
120 await _insert_cluster(
121 db_session,
122 str(repo.repo_id),
123 cluster_hash=f"sha256:exact{i:060d}",
124 tier="exact",
125 member_count=i + 2,
126 )
127 for i in range(2):
128 await _insert_cluster(
129 db_session,
130 str(repo.repo_id),
131 cluster_hash=f"sha256:near{i:061d}",
132 tier="near",
133 member_count=i + 4,
134 )
135 return repo
136
137
138 # ─────────────────────────────────────────────────────────────────────────────
139 # I01–I07 — List page
140 # ─────────────────────────────────────────────────────────────────────────────
141
142 class TestClonesListPage:
143 """Integration tests for GET /{owner}/{repo_slug}/intel/clones."""
144
145 @pytest.mark.asyncio
146 async def test_I01_list_200_with_clusters(
147 self, client: AsyncClient, repo_with_clusters: MusehubRepo
148 ) -> None:
149 """Seeded clusters render without error."""
150 r = await client.get(f"/testclones/clone-browser/intel/clones")
151 assert r.status_code == 200
152 assert b"cl-row" in r.content
153
154 @pytest.mark.asyncio
155 async def test_I02_list_200_empty_state(
156 self, client: AsyncClient, repo: MusehubRepo
157 ) -> None:
158 """Repo with no clusters renders the empty state at HTTP 200."""
159 r = await client.get(f"/testclones/clone-browser/intel/clones")
160 assert r.status_code == 200
161 assert b"intel.code.clones" in r.content
162
163 @pytest.mark.asyncio
164 async def test_I03_tier_exact_filter(
165 self, client: AsyncClient, repo_with_clusters: MusehubRepo
166 ) -> None:
167 """``?tier=exact`` shows only exact clusters."""
168 r = await client.get("/testclones/clone-browser/intel/clones?tier=exact")
169 assert r.status_code == 200
170 body = r.text
171 assert "cl-badge--exact" in body
172 assert "cl-badge--near" not in body
173
174 @pytest.mark.asyncio
175 async def test_I04_tier_near_filter(
176 self, client: AsyncClient, repo_with_clusters: MusehubRepo
177 ) -> None:
178 """``?tier=near`` shows only near clusters."""
179 r = await client.get("/testclones/clone-browser/intel/clones?tier=near")
180 assert r.status_code == 200
181 body = r.text
182 assert "cl-badge--near" in body
183 assert "cl-badge--exact" not in body
184
185 @pytest.mark.asyncio
186 async def test_I05_invalid_tier_coerces_to_all(
187 self, client: AsyncClient, repo_with_clusters: MusehubRepo
188 ) -> None:
189 """Invalid tier value returns 200 showing all clusters."""
190 r = await client.get("/testclones/clone-browser/intel/clones?tier=bogus")
191 assert r.status_code == 200
192 body = r.text
193 assert "cl-badge--exact" in body
194 assert "cl-badge--near" in body
195
196 @pytest.mark.asyncio
197 async def test_I06_top_50_pill_active(
198 self, client: AsyncClient, repo_with_clusters: MusehubRepo
199 ) -> None:
200 """``?top=50`` activates the 50 filter pill."""
201 r = await client.get("/testclones/clone-browser/intel/clones?top=50")
202 assert r.status_code == 200
203 assert b"top=50" in r.content
204
205 @pytest.mark.asyncio
206 async def test_I07_out_of_range_top_clamps_to_default(
207 self, client: AsyncClient, repo_with_clusters: MusehubRepo
208 ) -> None:
209 """``top=9999`` is not a valid top value; page returns 200 at default."""
210 r = await client.get("/testclones/clone-browser/intel/clones?top=9999")
211 assert r.status_code == 200
212
213
214 # ─────────────────────────────────────────────────────────────────────────────
215 # I08–I15 — Detail page
216 # ─────────────────────────────────────────────────────────────────────────────
217
218 class TestClonesDetailPage:
219 """Integration tests for GET /{owner}/{repo_slug}/intel/clones/detail."""
220
221 @pytest_asyncio.fixture
222 async def cross_file_cluster(self, db_session: AsyncSession, repo: MusehubRepo) -> str:
223 h = long_id("f" * 64)
224 await _insert_cluster(
225 db_session,
226 str(repo.repo_id),
227 cluster_hash=h,
228 tier="exact",
229 member_count=4,
230 members_json=_members(file_a="src/a.py", file_b="src/b.py", n=4),
231 )
232 return h
233
234 @pytest_asyncio.fixture
235 async def same_file_cluster(self, db_session: AsyncSession, repo: MusehubRepo) -> str:
236 h = long_id("e" * 64)
237 await _insert_cluster(
238 db_session,
239 str(repo.repo_id),
240 cluster_hash=h,
241 tier="near",
242 member_count=3,
243 members_json=_same_file_members(n=3),
244 )
245 return h
246
247 @pytest.mark.asyncio
248 async def test_I08_detail_200_known_hash(
249 self, client: AsyncClient, cross_file_cluster: str
250 ) -> None:
251 """Detail page renders at 200 for an existing cluster_hash."""
252 r = await client.get(
253 f"/testclones/clone-browser/intel/clones/detail"
254 f"?cluster={cross_file_cluster}"
255 )
256 assert r.status_code == 200
257 assert b"cl-member-row" in r.content
258
259 @pytest.mark.asyncio
260 async def test_I09_detail_200_unknown_hash(
261 self, client: AsyncClient, repo: MusehubRepo
262 ) -> None:
263 """Unknown hash renders empty state at HTTP 200, not 404 or 500."""
264 r = await client.get(
265 "/testclones/clone-browser/intel/clones/detail"
266 "?cluster=sha256:0000000000000000"
267 )
268 assert r.status_code == 200
269 assert b"No clone cluster found" in r.content
270
271 @pytest.mark.asyncio
272 async def test_I10_detail_200_no_cluster_param(
273 self, client: AsyncClient, repo: MusehubRepo
274 ) -> None:
275 """Missing cluster param renders empty state at HTTP 200."""
276 r = await client.get("/testclones/clone-browser/intel/clones/detail")
277 assert r.status_code == 200
278 assert b"No cluster specified" in r.content
279
280 @pytest.mark.asyncio
281 async def test_I11_dashboard_clones_card_with_data(
282 self, client: AsyncClient, db_session: AsyncSession, repo: MusehubRepo
283 ) -> None:
284 """Dashboard card shows cluster count when rows exist."""
285 await _insert_cluster(
286 db_session, str(repo.repo_id),
287 cluster_hash=long_id("d" * 64),
288 tier="exact", member_count=5,
289 )
290 r = await client.get("/testclones/clone-browser/intel")
291 assert r.status_code == 200
292 assert b"CLONES" in r.content
293
294 @pytest.mark.asyncio
295 async def test_I12_dashboard_clones_card_empty_state(
296 self, client: AsyncClient, repo: MusehubRepo
297 ) -> None:
298 """Dashboard renders clones card empty state without 500 when no rows."""
299 r = await client.get("/testclones/clone-browser/intel")
300 assert r.status_code == 200
301 assert b"No clone clusters yet" in r.content
302
303 @pytest.mark.asyncio
304 async def test_I13_detail_files_breakdown_present(
305 self, client: AsyncClient, cross_file_cluster: str
306 ) -> None:
307 """Detail page renders file breakdown section for cross-file cluster."""
308 r = await client.get(
309 f"/testclones/clone-browser/intel/clones/detail"
310 f"?cluster={cross_file_cluster}"
311 )
312 assert r.status_code == 200
313 assert b"cl-file-row" in r.content
314
315 @pytest.mark.asyncio
316 async def test_I14_cross_file_badge_present(
317 self, client: AsyncClient, cross_file_cluster: str
318 ) -> None:
319 """Cross-file cluster shows the cl-cross-file badge."""
320 r = await client.get(
321 f"/testclones/clone-browser/intel/clones/detail"
322 f"?cluster={cross_file_cluster}"
323 )
324 assert r.status_code == 200
325 assert b"cl-cross-file" in r.content
326
327 @pytest.mark.asyncio
328 async def test_I15_same_file_no_cross_file_badge(
329 self, client: AsyncClient, same_file_cluster: str
330 ) -> None:
331 """Same-file cluster does not show the cl-cross-file badge."""
332 r = await client.get(
333 f"/testclones/clone-browser/intel/clones/detail"
334 f"?cluster={same_file_cluster}"
335 )
336 assert r.status_code == 200
337 assert b"cl-cross-file" not in r.content
File History 2 commits
sha256:4992098130166d191cefed0a2821d19cd3cdd3cf50867a4e715c2b30636826c7 fix: repair syntax errors from typing annotation cleanup Sonnet 4.6 20 days ago
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 20 days ago