gabriel / musehub public
test_phase4_dead_dismiss.py python
272 lines 9.2 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 123 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 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=long_id("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 repo_id = dismiss_repo_with_row.repo_id # capture before expire_all
148 await client.post(
149 f"/{_OWNER}/{_SLUG}/intel/dead/dismiss",
150 data={"address": "pkg/a.py::target_fn"},
151 follow_redirects=False,
152 )
153 # Expire cached state so the next select hits the DB
154 db_session.expire_all()
155 row = await _get_dead_row(db_session, repo_id, "pkg/a.py::target_fn")
156 assert row is not None
157 assert row.dismissed is True
158
159
160 # ---------------------------------------------------------------------------
161 # Layer 4 — Dismissed row hidden by default
162 # ---------------------------------------------------------------------------
163
164 class TestDismissHidden:
165
166 @pytest.mark.asyncio
167 async def test_P4_04_dismissed_row_absent_from_default_list(
168 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo
169 ) -> None:
170 await db_session.commit()
171 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/b.py::hidden_fn", dismissed=True)
172 await db_session.commit()
173
174 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead")
175 assert "hidden_fn" not in resp.text
176
177
178 # ---------------------------------------------------------------------------
179 # Layer 5 — Dismissed row visible with flag
180 # ---------------------------------------------------------------------------
181
182 class TestDismissVisible:
183
184 @pytest.mark.asyncio
185 async def test_P4_05_dismissed_row_present_with_show_dismissed(
186 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo
187 ) -> None:
188 await db_session.commit()
189 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/c.py::visible_fn", dismissed=True)
190 await db_session.commit()
191
192 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead?show_dismissed=true")
193 assert "visible_fn" in resp.text
194
195
196 # ---------------------------------------------------------------------------
197 # Layer 6 — Dismissed stat count
198 # ---------------------------------------------------------------------------
199
200 class TestDismissStatCard:
201
202 @pytest.mark.asyncio
203 async def test_P4_06_dismissed_count_reflects_dismissed_rows(
204 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo
205 ) -> None:
206 await db_session.commit()
207 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/d.py::fn1", dismissed=True)
208 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/e.py::fn2", dismissed=True)
209 await db_session.commit()
210
211 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead")
212 assert resp.status_code == 200
213 # "2" must appear somewhere in the dismissed stat card area
214 assert "2" in resp.text
215
216
217 # ---------------------------------------------------------------------------
218 # Layer 7 — Unknown address → 404
219 # ---------------------------------------------------------------------------
220
221 class TestDismissNotFound:
222
223 @pytest.mark.asyncio
224 async def test_P4_07_unknown_address_returns_404(
225 self, client: AsyncClient, dismiss_repo
226 ) -> None:
227 resp = await client.post(
228 f"/{_OWNER}/{_SLUG}/intel/dead/dismiss",
229 data={"address": "no/such.py::fn"},
230 follow_redirects=False,
231 )
232 assert resp.status_code == 404
233
234
235 # ---------------------------------------------------------------------------
236 # Layer 8 — Dismiss button in HTML
237 # ---------------------------------------------------------------------------
238
239 class TestDismissButton:
240
241 @pytest.mark.asyncio
242 async def test_P4_08_dismiss_button_rendered_per_row(
243 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo
244 ) -> None:
245 await db_session.commit()
246 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/f.py::btn_fn")
247 await db_session.commit()
248
249 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead")
250 assert "dead/dismiss" in resp.text
251
252
253 # ---------------------------------------------------------------------------
254 # Layer 9 — Idempotent dismiss
255 # ---------------------------------------------------------------------------
256
257 class TestDismissIdempotent:
258
259 @pytest.mark.asyncio
260 async def test_P4_09_dismiss_already_dismissed_row_no_error(
261 self, client: AsyncClient, db_session: AsyncSession, dismiss_repo
262 ) -> None:
263 await db_session.commit()
264 await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/g.py::idem_fn", dismissed=True)
265 await db_session.commit()
266
267 resp = await client.post(
268 f"/{_OWNER}/{_SLUG}/intel/dead/dismiss",
269 data={"address": "pkg/g.py::idem_fn"},
270 follow_redirects=False,
271 )
272 assert resp.status_code == 302
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 123 days ago