"""TDD spec for Phase 2 — /intel/stable list page route (issue #12).
Route:
GET /{owner}/{repo_slug}/intel/stable
?since_start=true — filter to eternal-only symbols
?top=N — limit rows (25 / 50 / 100)
Returns 200 with stat row (eternal, veteran, total, oldest) and a
days_stable-DESC ranked list. Empty state when no data exists.
Seven test tiers
----------------
Unit P2_01 Route registered
Integration P2_02 – P2_08 HTTP responses, filters, stat counts, HTML content
E2E P2_09 – P2_11 Full seed → HTML round-trip
Stress P2_12 – P2_13 200-row render, top=100 limit
Data Integrity P2_14 – P2_15 Repo isolation, sort order
Performance P2_16 – P2_17 Response time, ordering
Security P2_18 – P2_20 XSS, path traversal, IDOR
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from httpx import AsyncClient
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 MusehubIntelStable
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 = "stableroute"
_REF = long_id("c" * 64)
async def _seed_stable(
session: AsyncSession,
repo_id: str,
*,
address: str,
days_stable: int = 180,
since_start: bool = False,
last_changed_commit: str | None = None,
symbol_kind: str | None = None,
) -> None:
"""Insert a ``musehub_intel_stable`` row for test fixtures.
Parameters
----------
session: Active async session.
repo_id: Target repository ID.
address: Symbol address (``file.py::fn``).
days_stable: Days since last modification.
since_start: True when symbol has never been modified.
last_changed_commit: Commit ID of last modification.
symbol_kind: Symbol kind (function, method, class, async_method).
"""
stmt = (
pg_insert(MusehubIntelStable)
.values(
repo_id=repo_id,
address=address,
days_stable=days_stable,
since_start=since_start,
last_changed_commit=last_changed_commit,
symbol_kind=symbol_kind,
ref=_REF,
)
.on_conflict_do_update(
index_elements=["repo_id", "address"],
set_={"days_stable": days_stable, "since_start": since_start,
"symbol_kind": symbol_kind},
)
)
await session.execute(stmt)
await session.flush()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def route_repo(db_session: AsyncSession) -> MusehubRepo:
"""Bare repo — no stable rows."""
return await create_repo(db_session, owner=_OWNER, slug=_SLUG)
@pytest_asyncio.fixture
async def route_repo_with_symbols(db_session: AsyncSession, route_repo: MusehubRepo) -> MusehubRepo:
"""Repo with a varied set of stable symbols."""
repo_id = route_repo.repo_id
await db_session.commit()
await _seed_stable(db_session, repo_id, address="pkg/core.py::parse",
days_stable=400, since_start=False,
last_changed_commit=_REF, symbol_kind="function")
await _seed_stable(db_session, repo_id, address="pkg/codec.py::pack",
days_stable=900, since_start=True,
last_changed_commit=None, symbol_kind="function")
await _seed_stable(db_session, repo_id, address="pkg/utils.py::sha256",
days_stable=200, since_start=False,
last_changed_commit=_REF, symbol_kind="method")
await db_session.commit()
return route_repo
# ---------------------------------------------------------------------------
# Tier 1 — Unit
# ---------------------------------------------------------------------------
class TestStableRouteUnit:
"""Unit test — route registration."""
def test_P2_01_route_registered(self) -> None:
"""intel/stable route must be registered in the ui_intel router."""
from musehub.api.routes.musehub.ui_intel import router
paths = [r.path for r in router.routes]
assert any("intel/stable" in p for p in paths)
# ---------------------------------------------------------------------------
# Tier 2 — Integration
# ---------------------------------------------------------------------------
class TestStableRouteIntegration:
"""Integration tests — HTTP responses against the real DB."""
@pytest.mark.asyncio
async def test_P2_02_empty_state_returns_200(
self, client: AsyncClient, route_repo: MusehubRepo
) -> None:
"""Empty stable table → 200 with empty-state message."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_P2_03_populated_returns_200(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Populated repo → 200."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_P2_04_since_start_filter(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""?since_start=true shows only eternal symbols."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"since_start": "true"})
assert resp.status_code == 200
# Only pkg/codec.py::pack has since_start=True
assert "pack" in resp.text
assert "parse" not in resp.text
@pytest.mark.asyncio
async def test_P2_05_top_param_limits_rows(
self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo
) -> None:
"""?top=2 returns at most 2 symbol rows."""
repo_id = route_repo.repo_id
await db_session.commit()
for i in range(5):
await _seed_stable(db_session, repo_id,
address=f"pkg/top{i}.py::fn",
days_stable=100 + i)
await db_session.commit()
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"top": 25})
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_P2_06_stat_eternal_count_correct(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Eternal count stat reflects since_start=True rows only."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
# 1 eternal symbol (pkg/codec.py::pack)
assert "1" in resp.text
@pytest.mark.asyncio
async def test_P2_07_address_in_html(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Symbol address fragments appear in the rendered HTML."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
assert "parse" in resp.text
assert "pack" in resp.text
@pytest.mark.asyncio
async def test_P2_08_days_stable_value_in_html(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""days_stable value (400d) appears in the HTML output."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
assert "400" in resp.text
@pytest.mark.asyncio
async def test_P2_08b_kind_filter_restricts_rows(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""?kind=method shows only method symbols (sha256), not function symbols."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"kind": "method"})
assert resp.status_code == 200
# sha256 is a method; parse and pack are functions — their file paths must not appear
assert "sha256" in resp.text
assert "pkg/core.py" not in resp.text # parse is function
assert "pkg/codec.py" not in resp.text # pack is function
@pytest.mark.asyncio
async def test_P2_08c_invalid_kind_returns_all(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""?kind=bogus (invalid) is silently ignored — all rows returned."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"kind": "bogus"})
assert resp.status_code == 200
assert "parse" in resp.text
assert "pkg/codec.py" in resp.text # codec.py::pack is unique
@pytest.mark.asyncio
async def test_P2_08d_symbol_kind_appears_in_html(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""symbol_kind values (function, method) appear in the rendered HTML."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
assert "function" in resp.text
assert "method" in resp.text
# ---------------------------------------------------------------------------
# Tier 3 — E2E
# ---------------------------------------------------------------------------
class TestStableRouteE2E:
"""End-to-end tests — full seed-to-HTML round-trip."""
@pytest.mark.asyncio
async def test_P2_09_full_round_trip(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Seeded symbols appear in HTML with days and address."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
text = resp.text
assert "pack" in text
assert "900" in text
@pytest.mark.asyncio
async def test_P2_10_stat_row_rendered(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Stat row keywords (Eternal, Veteran, Total, Oldest) present."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
text = resp.text
assert "Eternal" in text
assert "Total" in text
@pytest.mark.asyncio
async def test_P2_11_filter_url_contains_top(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Filter bar links contain ?top= parameter."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
assert "top=" in resp.text
# ---------------------------------------------------------------------------
# Tier 4 — Stress
# ---------------------------------------------------------------------------
class TestStableRouteStress:
"""Stress tests — large row counts render without error."""
@pytest.mark.asyncio
async def test_P2_12_200_rows_render_without_error(
self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo
) -> None:
"""200 stable rows render to a 200 response without error."""
repo_id = route_repo.repo_id
await db_session.commit()
for i in range(200):
await _seed_stable(db_session, repo_id,
address=f"pkg/stress{i}.py::fn",
days_stable=100 + i)
await db_session.commit()
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"top": 100})
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_P2_13_top_100_returns_correct_count(
self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo
) -> None:
"""?top=100 with 150 rows renders exactly 100 symbol addresses."""
repo_id = route_repo.repo_id
await db_session.commit()
for i in range(150):
await _seed_stable(db_session, repo_id,
address=f"pkg/count{i}.py::fn",
days_stable=50 + i)
await db_session.commit()
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"top": 100})
# 100 rows × 2 occurrences each (title attr + symbol href)
assert resp.text.count("::fn") == 200
# ---------------------------------------------------------------------------
# Tier 5 — Data Integrity
# ---------------------------------------------------------------------------
class TestStableRouteDataIntegrity:
"""Data integrity tests — repo isolation and sort order."""
@pytest.mark.asyncio
async def test_P2_14_only_this_repo_rows_returned(
self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo
) -> None:
"""Rows from a different repo are not shown on this repo's page."""
repo_b = await create_repo(db_session, owner=_OWNER, slug="stableroute_b")
repo_id_b = repo_b.repo_id
await db_session.commit()
await _seed_stable(db_session, repo_id_b,
address="pkg/other.py::secret_fn",
days_stable=500)
await db_session.commit()
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
assert "secret_fn" not in resp.text
@pytest.mark.asyncio
async def test_P2_15_sort_order_days_stable_desc(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Rows are ordered by days_stable DESC — highest first."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
text = resp.text
# pack (900d) must appear before parse (400d) in the HTML
assert text.index("pack") < text.index("parse")
# ---------------------------------------------------------------------------
# Tier 6 — Performance
# ---------------------------------------------------------------------------
class TestStableRoutePerformance:
"""Performance tests — response time and sort correctness at scale."""
@pytest.mark.asyncio
async def test_P2_16_response_under_500ms(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Response time for 3 rows < 500ms."""
import time
start = time.monotonic()
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
elapsed = time.monotonic() - start
assert resp.status_code == 200
assert elapsed < 0.5
@pytest.mark.asyncio
async def test_P2_17_invalid_top_falls_back_to_default(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""?top=999 (invalid) silently falls back to default=50, returns 200."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"top": 999})
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Tier 7 — Security
# ---------------------------------------------------------------------------
class TestStableRouteSecurity:
"""Security tests — XSS, path traversal, IDOR."""
@pytest.mark.asyncio
async def test_P2_18_xss_in_address_escaped(
self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo
) -> None:
"""XSS payload in address is HTML-escaped, not executed."""
repo_id = route_repo.repo_id
xss = ""
await db_session.commit()
await _seed_stable(db_session, repo_id, address=xss, days_stable=100)
await db_session.commit()
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
assert resp.status_code == 200
assert "" not in resp.text
@pytest.mark.asyncio
async def test_P2_19_unknown_repo_returns_404(
self, client: AsyncClient, route_repo: MusehubRepo
) -> None:
"""Non-existent repo slug → 404."""
resp = await client.get(f"/{_OWNER}/does_not_exist_xyz/intel/stable")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_P2_20_idor_repo_b_rows_not_on_repo_a_page(
self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo
) -> None:
"""Rows belonging to repo B are not visible on repo A's stable page."""
repo_b = await create_repo(db_session, owner=_OWNER, slug="stableidor_b")
repo_id_b = repo_b.repo_id
await db_session.commit()
await _seed_stable(db_session, repo_id_b,
address="pkg/idor.py::private_fn",
days_stable=300)
await db_session.commit()
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable")
assert "private_fn" not in resp.text
# ---------------------------------------------------------------------------
# Tier 8 — Detail page
# ---------------------------------------------------------------------------
class TestStableDetailPage:
"""Integration tests for the /intel/stable/detail route."""
@pytest.mark.asyncio
async def test_P4_01_detail_returns_200_for_known_address(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Known address → 200 with symbol data rendered."""
resp = await client.get(
f"/{_OWNER}/{_SLUG}/intel/stable/detail",
params={"address": "pkg/codec.py::pack"},
)
assert resp.status_code == 200
assert "pack" in resp.text
@pytest.mark.asyncio
async def test_P4_02_detail_shows_days_stable(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Detail page shows days_stable value (400 for parse, not eternal)."""
resp = await client.get(
f"/{_OWNER}/{_SLUG}/intel/stable/detail",
params={"address": "pkg/core.py::parse"},
)
assert "400" in resp.text
@pytest.mark.asyncio
async def test_P4_03_detail_shows_empty_state_for_unknown_address(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""Unknown address → 200 with empty-state message (no symbol data)."""
resp = await client.get(
f"/{_OWNER}/{_SLUG}/intel/stable/detail",
params={"address": "pkg/does_not_exist.py::missing"},
)
assert resp.status_code == 200
assert "No stable data" in resp.text
@pytest.mark.asyncio
async def test_P4_04_detail_no_address_shows_empty_state(
self, client: AsyncClient, route_repo_with_symbols: MusehubRepo
) -> None:
"""No address query param → 200 with empty-state message."""
resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable/detail")
assert resp.status_code == 200
assert "No stable data" in resp.text
@pytest.mark.asyncio
async def test_P4_05_detail_xss_in_address_escaped(
self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo
) -> None:
"""XSS payload in address query param is HTML-escaped."""
xss = ""
resp = await client.get(
f"/{_OWNER}/{_SLUG}/intel/stable/detail",
params={"address": xss},
)
assert resp.status_code == 200
assert "