gabriel / musehub public
test_phase4_dead_dismiss.py python
273 lines 9.4 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 20 days ago
1 """TDD spec for Phase 4 — Dismiss affordance (issue #10).
2
3 New route:
4 POST /{owner}/{repo_slug}/intel/dead/dismiss
5 Body (form): address=<symbol_address>
6 Response: 302 redirect to /{owner}/{repo_slug}/intel/dead
7
8 Sets musehub_intel_dead.dismissed = True for (repo_id, address).
9 Dismissed rows hidden from default list; visible with ?show_dismissed=true.
10 Dismissed stat card count updates correctly.
11 Idempotent — dismissing an already-dismissed row is safe.
12
13 Layers:
14 1. Route — "dead/dismiss" registered in ui_intel router
15 2. Redirect — POST with valid address → 302 to /intel/dead
16 3. DB state — dismissed=True persisted after POST
17 4. Hidden — dismissed row absent from default list response
18 5. Visible — dismissed row present when ?show_dismissed=true
19 6. Stat card — dismissed count updates after dismiss
20 7. Not found — POST with unknown address → 404
21 8. Button — dismiss button rendered per row in list HTML
22 9. Idempotent — dismissing already-dismissed row returns 302 (no error)
23 """
24 from __future__ import annotations
25
26 import secrets
27
28 import pytest
29 import pytest_asyncio
30 from httpx import AsyncClient
31 from sqlalchemy import select
32 from sqlalchemy.dialects.postgresql import insert as pg_insert
33 from sqlalchemy.ext.asyncio import AsyncSession
34
35 from muse.core.types import fake_id, long_id
36 from musehub.db.musehub_intel_models import MusehubIntelDead
37 from musehub.db.musehub_repo_models import MusehubRepo
38 from tests.factories import create_repo
39
40
41 def _uid() -> str:
42 return fake_id(secrets.token_hex(16))
43
44
45 _OWNER = "testuser"
46 _SLUG = "deaddismissrepo"
47
48
49 async def _seed_dead(
50 session: AsyncSession,
51 repo_id: str,
52 *,
53 address: str,
54 confidence: str = "high",
55 dismissed: bool = False,
56 ) -> None:
57 stmt = (
58 pg_insert(MusehubIntelDead)
59 .values(
60 repo_id=repo_id,
61 address=address,
62 kind="function",
63 confidence=confidence,
64 reason="test reason",
65 ref=long_id("a" * 64),
66 dismissed=dismissed,
67 )
68 .on_conflict_do_nothing()
69 )
70 await session.execute(stmt)
71 await session.flush()
72
73
74 async def _get_dead_row(
75 session: AsyncSession, repo_id: str, address: str
76 ) -> MusehubIntelDead | None:
77 result = await session.execute(
78 select(MusehubIntelDead).where(
79 MusehubIntelDead.repo_id == repo_id,
80 MusehubIntelDead.address == address,
81 )
82 )
83 return result.scalar_one_or_none()
84
85
86 # ---------------------------------------------------------------------------
87 # Fixtures
88 # ---------------------------------------------------------------------------
89
90 @pytest_asyncio.fixture
91 async def dismiss_repo(db_session: AsyncSession) -> MusehubRepo:
92 return await create_repo(db_session, owner=_OWNER, slug=_SLUG)
93
94
95 @pytest_asyncio.fixture
96 async def dismiss_repo_with_row(db_session: AsyncSession, dismiss_repo: MusehubRepo) -> MusehubRepo:
97 await db_session.commit()
98 await _seed_dead(
99 db_session, dismiss_repo.repo_id,
100 address="pkg/a.py::target_fn",
101 confidence="high",
102 )
103 await db_session.commit()
104 return dismiss_repo
105
106
107 # ---------------------------------------------------------------------------
108 # Layer 1 — Route registration
109 # ---------------------------------------------------------------------------
110
111 class TestDismissRouteRegistration:
112
113 def test_P4_01_dismiss_route_registered(self) -> None:
114 from musehub.api.routes.musehub.ui_intel import router
115 paths = [r.path for r in router.routes]
116 assert any("dead/dismiss" in p for p in paths)
117
118
119 # ---------------------------------------------------------------------------
120 # Layer 2 — Redirect on valid POST
121 # ---------------------------------------------------------------------------
122
123 class TestDismissRedirect:
124
125 @pytest.mark.asyncio
126 async def test_P4_02_post_valid_address_redirects_302(
127 self, client: AsyncClient, dismiss_repo_with_row: MusehubRepo
128 ) -> None:
129 resp = await client.post(
130 f"/{_OWNER}/{_SLUG}/intel/dead/dismiss",
131 data={"address": "pkg/a.py::target_fn"},
132 follow_redirects=False,
133 )
134 assert resp.status_code == 302
135 assert "/intel/dead" in resp.headers["location"]
136
137
138 # ---------------------------------------------------------------------------
139 # Layer 3 — DB state after POST
140 # ---------------------------------------------------------------------------
141
142 class TestDismissDbState:
143
144 @pytest.mark.asyncio
145 async def test_P4_03_dismissed_true_in_db_after_post(
146 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo_with_row: MusehubRepo
147 ) -> None:
148 repo_id = dismiss_repo_with_row.repo_id # capture before expire_all
149 await client.post(
150 f"/{_OWNER}/{_SLUG}/intel/dead/dismiss",
151 data={"address": "pkg/a.py::target_fn"},
152 follow_redirects=False,
153 )
154 # Expire cached state so the next select hits the DB
155 db_session.expire_all()
156 row = await _get_dead_row(db_session, repo_id, "pkg/a.py::target_fn")
157 assert row is not None
158 assert row.dismissed is True
159
160
161 # ---------------------------------------------------------------------------
162 # Layer 4 — Dismissed row hidden by default
163 # ---------------------------------------------------------------------------
164
165 class TestDismissHidden:
166
167 @pytest.mark.asyncio
168 async def test_P4_04_dismissed_row_absent_from_default_list(
169 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo: MusehubRepo
170 ) -> None:
171 await db_session.commit()
172 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/b.py::hidden_fn", dismissed=True)
173 await db_session.commit()
174
175 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead")
176 assert "hidden_fn" not in resp.text
177
178
179 # ---------------------------------------------------------------------------
180 # Layer 5 — Dismissed row visible with flag
181 # ---------------------------------------------------------------------------
182
183 class TestDismissVisible:
184
185 @pytest.mark.asyncio
186 async def test_P4_05_dismissed_row_present_with_show_dismissed(
187 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo: MusehubRepo
188 ) -> None:
189 await db_session.commit()
190 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/c.py::visible_fn", dismissed=True)
191 await db_session.commit()
192
193 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead?show_dismissed=true")
194 assert "visible_fn" in resp.text
195
196
197 # ---------------------------------------------------------------------------
198 # Layer 6 — Dismissed stat count
199 # ---------------------------------------------------------------------------
200
201 class TestDismissStatCard:
202
203 @pytest.mark.asyncio
204 async def test_P4_06_dismissed_count_reflects_dismissed_rows(
205 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo: MusehubRepo
206 ) -> None:
207 await db_session.commit()
208 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/d.py::fn1", dismissed=True)
209 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/e.py::fn2", dismissed=True)
210 await db_session.commit()
211
212 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead")
213 assert resp.status_code == 200
214 # "2" must appear somewhere in the dismissed stat card area
215 assert "2" in resp.text
216
217
218 # ---------------------------------------------------------------------------
219 # Layer 7 — Unknown address → 404
220 # ---------------------------------------------------------------------------
221
222 class TestDismissNotFound:
223
224 @pytest.mark.asyncio
225 async def test_P4_07_unknown_address_returns_404(
226 self, client: AsyncClient, dismiss_repo: MusehubRepo
227 ) -> None:
228 resp = await client.post(
229 f"/{_OWNER}/{_SLUG}/intel/dead/dismiss",
230 data={"address": "no/such.py::fn"},
231 follow_redirects=False,
232 )
233 assert resp.status_code == 404
234
235
236 # ---------------------------------------------------------------------------
237 # Layer 8 — Dismiss button in HTML
238 # ---------------------------------------------------------------------------
239
240 class TestDismissButton:
241
242 @pytest.mark.asyncio
243 async def test_P4_08_dismiss_button_rendered_per_row(
244 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo: MusehubRepo
245 ) -> None:
246 await db_session.commit()
247 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/f.py::btn_fn")
248 await db_session.commit()
249
250 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead")
251 assert "dead/dismiss" in resp.text
252
253
254 # ---------------------------------------------------------------------------
255 # Layer 9 — Idempotent dismiss
256 # ---------------------------------------------------------------------------
257
258 class TestDismissIdempotent:
259
260 @pytest.mark.asyncio
261 async def test_P4_09_dismiss_already_dismissed_row_no_error(
262 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo: MusehubRepo
263 ) -> None:
264 await db_session.commit()
265 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/g.py::idem_fn", dismissed=True)
266 await db_session.commit()
267
268 resp = await client.post(
269 f"/{_OWNER}/{_SLUG}/intel/dead/dismiss",
270 data={"address": "pkg/g.py::idem_fn"},
271 follow_redirects=False,
272 )
273 assert resp.status_code == 302
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 20 days ago