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