"""TDD spec for Phase 4 — Dismiss affordance (issue #10). New route: POST /{owner}/{repo_slug}/intel/dead/dismiss Body (form): address= Response: 302 redirect to /{owner}/{repo_slug}/intel/dead Sets musehub_intel_dead.dismissed = True for (repo_id, address). Dismissed rows hidden from default list; visible with ?show_dismissed=true. Dismissed stat card count updates correctly. Idempotent — dismissing an already-dismissed row is safe. Layers: 1. Route — "dead/dismiss" registered in ui_intel router 2. Redirect — POST with valid address → 302 to /intel/dead 3. DB state — dismissed=True persisted after POST 4. Hidden — dismissed row absent from default list response 5. Visible — dismissed row present when ?show_dismissed=true 6. Stat card — dismissed count updates after dismiss 7. Not found — POST with unknown address → 404 8. Button — dismiss button rendered per row in list HTML 9. Idempotent — dismissing already-dismissed row returns 302 (no error) """ from __future__ import annotations import secrets import pytest import pytest_asyncio from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id, long_id from musehub.db.musehub_intel_models import MusehubIntelDead from musehub.db.musehub_repo_models import MusehubRepo from tests.factories import create_repo def _uid() -> str: return fake_id(secrets.token_hex(16)) _OWNER = "testuser" _SLUG = "deaddismissrepo" async def _seed_dead( session: AsyncSession, repo_id: str, *, address: str, confidence: str = "high", dismissed: bool = False, ) -> None: stmt = ( pg_insert(MusehubIntelDead) .values( repo_id=repo_id, address=address, kind="function", confidence=confidence, reason="test reason", ref=long_id("a" * 64), dismissed=dismissed, ) .on_conflict_do_nothing() ) await session.execute(stmt) await session.flush() async def _get_dead_row( session: AsyncSession, repo_id: str, address: str ) -> MusehubIntelDead | None: result = await session.execute( select(MusehubIntelDead).where( MusehubIntelDead.repo_id == repo_id, MusehubIntelDead.address == address, ) ) return result.scalar_one_or_none() # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def dismiss_repo(db_session: AsyncSession) -> MusehubRepo: return await create_repo(db_session, owner=_OWNER, slug=_SLUG) @pytest_asyncio.fixture async def dismiss_repo_with_row(db_session: AsyncSession, dismiss_repo: MusehubRepo) -> MusehubRepo: await db_session.commit() await _seed_dead( db_session, dismiss_repo.repo_id, address="pkg/a.py::target_fn", confidence="high", ) await db_session.commit() return dismiss_repo # --------------------------------------------------------------------------- # Layer 1 — Route registration # --------------------------------------------------------------------------- class TestDismissRouteRegistration: def test_P4_01_dismiss_route_registered(self) -> None: from musehub.api.routes.musehub.ui_intel import router paths = [r.path for r in router.routes] assert any("dead/dismiss" in p for p in paths) # --------------------------------------------------------------------------- # Layer 2 — Redirect on valid POST # --------------------------------------------------------------------------- class TestDismissRedirect: @pytest.mark.asyncio async def test_P4_02_post_valid_address_redirects_302( self, client: AsyncClient, dismiss_repo_with_row: MusehubRepo ) -> None: resp = await client.post( f"/{_OWNER}/{_SLUG}/intel/dead/dismiss", data={"address": "pkg/a.py::target_fn"}, follow_redirects=False, ) assert resp.status_code == 302 assert "/intel/dead" in resp.headers["location"] # --------------------------------------------------------------------------- # Layer 3 — DB state after POST # --------------------------------------------------------------------------- class TestDismissDbState: @pytest.mark.asyncio async def test_P4_03_dismissed_true_in_db_after_post( self, client: AsyncClient, db_session: AsyncSession, dismiss_repo_with_row: MusehubRepo ) -> None: repo_id = dismiss_repo_with_row.repo_id # capture before expire_all await client.post( f"/{_OWNER}/{_SLUG}/intel/dead/dismiss", data={"address": "pkg/a.py::target_fn"}, follow_redirects=False, ) # Expire cached state so the next select hits the DB db_session.expire_all() row = await _get_dead_row(db_session, repo_id, "pkg/a.py::target_fn") assert row is not None assert row.dismissed is True # --------------------------------------------------------------------------- # Layer 4 — Dismissed row hidden by default # --------------------------------------------------------------------------- class TestDismissHidden: @pytest.mark.asyncio async def test_P4_04_dismissed_row_absent_from_default_list( self, client: AsyncClient, db_session: AsyncSession, dismiss_repo: MusehubRepo ) -> None: await db_session.commit() await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/b.py::hidden_fn", dismissed=True) await db_session.commit() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead") assert "hidden_fn" not in resp.text # --------------------------------------------------------------------------- # Layer 5 — Dismissed row visible with flag # --------------------------------------------------------------------------- class TestDismissVisible: @pytest.mark.asyncio async def test_P4_05_dismissed_row_present_with_show_dismissed( self, client: AsyncClient, db_session: AsyncSession, dismiss_repo: MusehubRepo ) -> None: await db_session.commit() await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/c.py::visible_fn", dismissed=True) await db_session.commit() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead?show_dismissed=true") assert "visible_fn" in resp.text # --------------------------------------------------------------------------- # Layer 6 — Dismissed stat count # --------------------------------------------------------------------------- class TestDismissStatCard: @pytest.mark.asyncio async def test_P4_06_dismissed_count_reflects_dismissed_rows( self, client: AsyncClient, db_session: AsyncSession, dismiss_repo: MusehubRepo ) -> None: await db_session.commit() await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/d.py::fn1", dismissed=True) await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/e.py::fn2", dismissed=True) await db_session.commit() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead") assert resp.status_code == 200 # "2" must appear somewhere in the dismissed stat card area assert "2" in resp.text # --------------------------------------------------------------------------- # Layer 7 — Unknown address → 404 # --------------------------------------------------------------------------- class TestDismissNotFound: @pytest.mark.asyncio async def test_P4_07_unknown_address_returns_404( self, client: AsyncClient, dismiss_repo: MusehubRepo ) -> None: resp = await client.post( f"/{_OWNER}/{_SLUG}/intel/dead/dismiss", data={"address": "no/such.py::fn"}, follow_redirects=False, ) assert resp.status_code == 404 # --------------------------------------------------------------------------- # Layer 8 — Dismiss button in HTML # --------------------------------------------------------------------------- class TestDismissButton: @pytest.mark.asyncio async def test_P4_08_dismiss_button_rendered_per_row( self, client: AsyncClient, db_session: AsyncSession, dismiss_repo: MusehubRepo ) -> None: await db_session.commit() await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/f.py::btn_fn") await db_session.commit() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/dead") assert "dead/dismiss" in resp.text # --------------------------------------------------------------------------- # Layer 9 — Idempotent dismiss # --------------------------------------------------------------------------- class TestDismissIdempotent: @pytest.mark.asyncio async def test_P4_09_dismiss_already_dismissed_row_no_error( self, client: AsyncClient, db_session: AsyncSession, dismiss_repo: MusehubRepo ) -> None: await db_session.commit() await _seed_dead(db_session, dismiss_repo.repo_id, address="pkg/g.py::idem_fn", dismissed=True) await db_session.commit() resp = await client.post( f"/{_OWNER}/{_SLUG}/intel/dead/dismiss", data={"address": "pkg/g.py::idem_fn"}, follow_redirects=False, ) assert resp.status_code == 302