"""API Surface dashboard — full 7-tier test suite (issue #19).
Tests are written TDD-first: all tests in this file must be RED before
Phase 3–5 implementation begins, then GREEN after.
Tiers:
T01–T03 Layer T1 — DB model (composite PK, nullable fields, cascade)
T04–T06 Layer T2 — Provider batch performance
T07–T15 Layer T3 — Route (unit / integration)
T16–T19 Layer T4 — E2E (HTML body assertions)
T20–T22 Layer T5 — State integrity
T23–T25 Layer T6 — Performance
T26–T30 Layer T7 — Security
"""
from __future__ import annotations
import time
from unittest.mock import AsyncMock, patch
import pytest
import sqlalchemy as sa
from httpx import AsyncClient
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from musehub.db.musehub_intel_models import MusehubIntelApiSurface
from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubSnapshot, MusehubSnapshotRef
from musehub.types.json_types import JSONObject
from tests.factories import create_repo
from muse.core.types import long_id
_REF = long_id("b" * 64)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _insert_as_row(
session: AsyncSession,
repo_id: str,
address: str,
kind: str = "function",
signature_id: str | None = None,
visibility: str = "public",
ref: str = _REF,
) -> None:
"""Upsert one row into musehub_intel_api_surface."""
await session.execute(
pg_insert(MusehubIntelApiSurface)
.values(
repo_id=repo_id,
address=address,
kind=kind,
signature_id=signature_id,
visibility=visibility,
ref=ref,
)
.on_conflict_do_update(
index_elements=["repo_id", "address"],
set_={
"kind": kind,
"signature_id": signature_id,
"visibility": visibility,
"ref": ref,
},
)
)
import pytest_asyncio
@pytest_asyncio.fixture
async def as_repo(db_session: AsyncSession):
"""Repo seeded with one symbol of each kind."""
repo = await create_repo(db_session, owner="asuser", slug="as-e2e")
rid = str(repo.repo_id)
await _insert_as_row(db_session, rid, "src/billing.py::compute_total",
kind="function")
await _insert_as_row(db_session, rid, "src/billing.py::async_fetch",
kind="async_function")
await _insert_as_row(db_session, rid, "src/models.py::UserRecord",
kind="class")
await _insert_as_row(db_session, rid, "src/models.py::UserRecord.save",
kind="method")
await _insert_as_row(db_session, rid, "src/models.py::UserRecord.async_load",
kind="async_method")
await db_session.commit()
return repo
# ─────────────────────────────────────────────────────────────────────────────
# Layer T1 — DB model
# ─────────────────────────────────────────────────────────────────────────────
class TestDBModel:
def test_T01_model_has_required_columns(self) -> None:
"""MusehubIntelApiSurface must declare all expected mapped columns."""
cols = {c.key for c in sa.inspect(MusehubIntelApiSurface).mapper.column_attrs}
for required in ("repo_id", "address", "kind", "signature_id", "visibility", "ref"):
assert required in cols, f"Column '{required}' missing from MusehubIntelApiSurface"
def test_T02_signature_id_is_nullable(self) -> None:
"""signature_id must be nullable — not all symbols have a signature object."""
col = MusehubIntelApiSurface.__table__.c["signature_id"]
assert col.nullable, "signature_id must be nullable"
@pytest.mark.asyncio
async def test_T03_row_insert_and_cascade_delete(
self, db_session: AsyncSession
) -> None:
"""Row inserts cleanly; deleting the repo cascades to api_surface rows."""
repo = await create_repo(db_session, owner="asuser", slug="t03-cascade")
rid = str(repo.repo_id)
await _insert_as_row(db_session, rid, "src/x.py::fn")
await db_session.commit()
# row present
row = await db_session.scalar(
sa.select(MusehubIntelApiSurface).where(
MusehubIntelApiSurface.repo_id == rid,
MusehubIntelApiSurface.address == "src/x.py::fn",
)
)
assert row is not None, "Row not found after insert"
# cascade delete
await db_session.delete(repo)
await db_session.commit()
remaining = (await db_session.execute(
sa.select(MusehubIntelApiSurface).where(
MusehubIntelApiSurface.repo_id == rid
)
)).scalars().all()
assert not remaining, "Cascade delete failed — api_surface rows remain after repo delete"
# ─────────────────────────────────────────────────────────────────────────────
# Layer T2 — Provider batch performance
# ─────────────────────────────────────────────────────────────────────────────
async def _seed_snapshot(
session: AsyncSession,
repo_id: str,
manifest: dict[str, str],
) -> str:
"""Insert a MusehubCommit + MusehubSnapshot and return the snapshot_id."""
import msgpack
from datetime import datetime, timezone
snap_id = long_id("c" * 64)
commit_id = long_id("d" * 64)
await session.execute(
pg_insert(MusehubSnapshot)
.values(
snapshot_id=snap_id,
directories=[],
manifest_blob=msgpack.packb(manifest),
entry_count=len(manifest),
created_at=datetime.now(timezone.utc),
)
.on_conflict_do_nothing()
)
await session.execute(
pg_insert(MusehubSnapshotRef)
.values(repo_id=repo_id, snapshot_id=snap_id)
.on_conflict_do_nothing()
)
await session.execute(
pg_insert(MusehubCommit)
.values(
commit_id=commit_id,
branch="dev",
parent_ids=[],
message="test",
author="asuser",
timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
snapshot_id=snap_id,
)
.on_conflict_do_nothing()
)
await session.execute(
pg_insert(MusehubCommitRef)
.values(repo_id=repo_id, commit_id=commit_id)
.on_conflict_do_nothing()
)
await session.commit()
return snap_id
def _fake_tree(n: int, prefix: str = "fn") -> JSONObject:
"""Return a SymbolTree dict with *n* public function symbols."""
return {
f"src/file.py::{prefix}_{i}": {
"kind": "function",
"name": f"{prefix}_{i}",
"qualified_name": f"{prefix}_{i}",
"content_id": long_id("a" * 64),
"body_hash": long_id("b" * 64),
"signature_id": long_id("c" * 64),
"metadata_id": "",
"canonical_key": f"src/file.py##function#{prefix}_{i}#1",
"lineno": i + 1,
"end_lineno": i + 2,
}
for i in range(n)
}
class TestProviderBatch:
@pytest.mark.asyncio
async def test_T04_provider_issues_one_sql_per_chunk(
self, db_session: AsyncSession
) -> None:
"""ApiSurfaceProvider must batch-upsert, not execute one statement per symbol."""
from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
repo = await create_repo(db_session, owner="asuser", slug="t04-batch")
rid = str(repo.repo_id)
await _seed_snapshot(db_session, rid, {"src/file.py": long_id("e" * 64)})
execute_calls: list[sa.Executable] = []
original_execute = db_session.execute
async def counting_execute(stmt, *args, **kwargs):
execute_calls.append(stmt)
return await original_execute(stmt, *args, **kwargs)
mock_backend = AsyncMock()
mock_backend.get = AsyncMock(return_value=b"# placeholder")
with (
patch("musehub.services.musehub_intel_providers.get_backend",
return_value=mock_backend),
patch("musehub.services.musehub_intel_providers.parse_symbols",
return_value=_fake_tree(50)),
):
db_session.execute = counting_execute # type: ignore[method-assign]
await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
db_session, rid, _REF,
{"owner": repo.owner, "slug": repo.slug},
)
db_session.execute = original_execute # type: ignore[method-assign]
# 50 symbols fit in one chunk — expect exactly 1 INSERT execute
insert_calls = [
c for c in execute_calls
if "insert" in str(type(c).__name__).lower() or "insert" in str(c).lower()
]
assert len(insert_calls) == 1, (
f"Expected 1 batch upsert for 50 symbols, got {len(insert_calls)}"
)
@pytest.mark.asyncio
async def test_T05_provider_uses_ceil_n_over_1000_sql_calls_for_2500_symbols(
self, db_session: AsyncSession
) -> None:
"""2,500 symbols → exactly 3 INSERT statements (ceil(2500/1000) = 3)."""
from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
repo = await create_repo(db_session, owner="asuser", slug="t05-chunks")
rid = str(repo.repo_id)
await _seed_snapshot(db_session, rid, {"src/big.py": long_id("f" * 64)})
execute_calls: list[sa.Executable] = []
original_execute = db_session.execute
async def counting_execute(stmt, *args, **kwargs):
execute_calls.append(stmt)
return await original_execute(stmt, *args, **kwargs)
mock_backend = AsyncMock()
mock_backend.get = AsyncMock(return_value=b"# placeholder")
with (
patch("musehub.services.musehub_intel_providers.get_backend",
return_value=mock_backend),
patch("musehub.services.musehub_intel_providers.parse_symbols",
return_value=_fake_tree(2500)),
):
db_session.execute = counting_execute # type: ignore[method-assign]
result = await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
db_session, rid, _REF,
{"owner": repo.owner, "slug": repo.slug},
)
db_session.execute = original_execute # type: ignore[method-assign]
insert_calls = [
c for c in execute_calls
if "insert" in str(type(c).__name__).lower() or "insert" in str(c).lower()
]
assert len(insert_calls) == 3, (
f"2500 symbols should produce 3 INSERT chunks, got {len(insert_calls)}"
)
assert result == [("intel.code.api_surface", {"count": 2500})]
@pytest.mark.asyncio
async def test_T06_empty_symbols_returns_empty_list(
self, db_session: AsyncSession
) -> None:
"""Provider must return [] and issue no INSERTs when parse_symbols yields nothing."""
from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
repo = await create_repo(db_session, owner="asuser", slug="t06-empty")
rid = str(repo.repo_id)
await _seed_snapshot(db_session, rid, {"src/empty.py": long_id("a" * 64)})
execute_calls: list[sa.Executable] = []
original_execute = db_session.execute
async def counting_execute(stmt, *args, **kwargs):
execute_calls.append(stmt)
return await original_execute(stmt, *args, **kwargs)
mock_backend = AsyncMock()
mock_backend.get = AsyncMock(return_value=b"# no public symbols")
with (
patch("musehub.services.musehub_intel_providers.get_backend",
return_value=mock_backend),
patch("musehub.services.musehub_intel_providers.parse_symbols",
return_value={}),
):
db_session.execute = counting_execute # type: ignore[method-assign]
result = await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
db_session, rid, _REF,
{"owner": repo.owner, "slug": repo.slug},
)
db_session.execute = original_execute # type: ignore[method-assign]
assert result == [], "Empty symbols list must return []"
insert_calls = [c for c in execute_calls if "insert" in str(c).lower()]
assert len(insert_calls) == 0, "No DB writes expected for empty symbol list"
# ─────────────────────────────────────────────────────────────────────────────
# Layer T3 — Route (unit / integration)
# ─────────────────────────────────────────────────────────────────────────────
class TestRoute:
@pytest.mark.asyncio
async def test_T07_returns_200_with_empty_repo(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
"""Route must return 200 even when musehub_intel_api_surface has no rows."""
await create_repo(db_session, owner="asuser", slug="t07-empty")
await db_session.commit()
r = await client.get("/asuser/t07-empty/intel/api-surface")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_T08_returns_200_with_data(
self, client: AsyncClient, as_repo
) -> None:
"""Route returns 200 when rows exist."""
r = await client.get("/asuser/as-e2e/intel/api-surface")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_T09_kind_filter_function_only(
self, client: AsyncClient, as_repo
) -> None:
"""?kind=function returns only function symbols, not class or method."""
r = await client.get("/asuser/as-e2e/intel/api-surface?kind=function")
assert r.status_code == 200
assert "compute_total" in r.text
assert "UserRecord.save" not in r.text
assert "UserRecord" not in r.text or "compute_total" in r.text
@pytest.mark.asyncio
async def test_T10_kind_filter_class_only(
self, client: AsyncClient, as_repo
) -> None:
"""?kind=class returns only class symbols."""
r = await client.get("/asuser/as-e2e/intel/api-surface?kind=class")
assert r.status_code == 200
assert "UserRecord" in r.text
assert "compute_total" not in r.text
@pytest.mark.asyncio
async def test_T11_kind_filter_async_function(
self, client: AsyncClient, as_repo
) -> None:
"""?kind=async_function returns only async_function symbols."""
r = await client.get("/asuser/as-e2e/intel/api-surface?kind=async_function")
assert r.status_code == 200
assert "async_fetch" in r.text
assert "compute_total" not in r.text
@pytest.mark.asyncio
async def test_T12_unknown_kind_coerced_to_all(
self, client: AsyncClient, as_repo
) -> None:
"""?kind=garbage must return 200 (treated as no filter), not 400/500."""
r = await client.get("/asuser/as-e2e/intel/api-surface?kind=garbage")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_T13_top_param_limits_results(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
"""?top=20 returns at most 20 symbols even when 25 exist."""
repo = await create_repo(db_session, owner="asuser", slug="t13-top")
rid = str(repo.repo_id)
for i in range(25):
await _insert_as_row(db_session, rid,
f"src/f{i}.py::fn_{i}", kind="function")
await db_session.commit()
r = await client.get("/asuser/t13-top/intel/api-surface?top=20")
assert r.status_code == 200
count = sum(1 for i in range(25) if f"src/f{i}.py::fn_{i}" in r.text)
assert count <= 20, f"Expected ≤20 results for ?top=20, got {count}"
@pytest.mark.asyncio
async def test_T14_top_invalid_string_returns_422(
self, client: AsyncClient, as_repo
) -> None:
"""?top=notanumber must be rejected with 422 (FastAPI type validation)."""
r = await client.get("/asuser/as-e2e/intel/api-surface?top=notanumber")
assert r.status_code == 422
@pytest.mark.asyncio
async def test_T15_unknown_repo_returns_404(
self, client: AsyncClient
) -> None:
"""Non-existent repo path must return 404, not 200 or 500."""
r = await client.get("/nobody/no-such-repo/intel/api-surface")
assert r.status_code in (403, 404)
# ─────────────────────────────────────────────────────────────────────────────
# Layer T4 — E2E (HTML body assertions)
# ─────────────────────────────────────────────────────────────────────────────
class TestE2E:
@pytest.mark.asyncio
async def test_T16_total_count_chip_shows_correct_value(
self, client: AsyncClient, as_repo
) -> None:
"""Stat chip for Total must reflect the DB row count (5 symbols seeded)."""
r = await client.get("/asuser/as-e2e/intel/api-surface")
assert r.status_code == 200
# 5 symbols seeded in fixture; total chip must contain "5"
assert "5" in r.text
@pytest.mark.asyncio
async def test_T17_kind_breakdown_chips_present(
self, client: AsyncClient, as_repo
) -> None:
"""Kind breakdown stat chips must appear for all five kinds."""
r = await client.get("/asuser/as-e2e/intel/api-surface")
assert r.status_code == 200
body = r.text.lower()
for kind_label in ("function", "class", "method"):
assert kind_label in body, f"Kind label '{kind_label}' missing from page"
@pytest.mark.asyncio
async def test_T18_symbol_address_split_rendered(
self, client: AsyncClient, as_repo
) -> None:
"""Symbol file and name parts must both appear in the HTML."""
r = await client.get("/asuser/as-e2e/intel/api-surface")
assert r.status_code == 200
# file part
assert "src/billing.py" in r.text
# name part
assert "compute_total" in r.text
@pytest.mark.asyncio
async def test_T19_dashboard_card_links_to_api_surface_page(
self, client: AsyncClient, as_repo
) -> None:
"""Intel dashboard must include a link to /intel/api-surface."""
r = await client.get("/asuser/as-e2e/intel")
assert r.status_code == 200
assert b"/intel/api-surface" in r.content
# ─────────────────────────────────────────────────────────────────────────────
# Layer T5 — State integrity
# ─────────────────────────────────────────────────────────────────────────────
class TestStateIntegrity:
@pytest.mark.asyncio
async def test_T20_double_upsert_produces_one_row(
self, db_session: AsyncSession
) -> None:
"""Upserting the same address twice must not create duplicate rows."""
repo = await create_repo(db_session, owner="asuser", slug="t20-dup")
rid = str(repo.repo_id)
addr = "src/a.py::fn"
for _ in range(2):
await _insert_as_row(db_session, rid, addr, kind="function")
await db_session.commit()
rows = (await db_session.execute(
sa.select(MusehubIntelApiSurface).where(
MusehubIntelApiSurface.repo_id == rid
)
)).scalars().all()
assert len(rows) == 1, f"Expected 1 row, got {len(rows)} — upsert created duplicates"
@pytest.mark.asyncio
async def test_T21_second_upsert_overwrites_kind(
self, db_session: AsyncSession
) -> None:
"""A second upsert with a different kind must overwrite the first."""
repo = await create_repo(db_session, owner="asuser", slug="t21-overwrite")
rid = str(repo.repo_id)
addr = "src/a.py::Foo"
await _insert_as_row(db_session, rid, addr, kind="class")
await _insert_as_row(db_session, rid, addr, kind="function")
await db_session.commit()
row = await db_session.scalar(
sa.select(MusehubIntelApiSurface).where(
MusehubIntelApiSurface.repo_id == rid,
MusehubIntelApiSurface.address == addr,
)
)
assert row is not None
assert row.kind == "function", (
f"Expected kind='function' after second upsert, got '{row.kind}'"
)
@pytest.mark.asyncio
async def test_T22_cross_repo_isolation(
self, db_session: AsyncSession
) -> None:
"""Symbols from repo A must not appear under repo B's page URL."""
repo_a = await create_repo(db_session, owner="asuser", slug="t22-repo-a")
repo_b = await create_repo(db_session, owner="asuser", slug="t22-repo-b")
await _insert_as_row(db_session, str(repo_a.repo_id),
"src/secret.py::private_fn", kind="function")
await db_session.commit()
rows_b = (await db_session.execute(
sa.select(MusehubIntelApiSurface).where(
MusehubIntelApiSurface.repo_id == str(repo_b.repo_id)
)
)).scalars().all()
assert not rows_b, "Repo B must not see Repo A's api_surface symbols"
# ─────────────────────────────────────────────────────────────────────────────
# Layer T6 — Performance
# ─────────────────────────────────────────────────────────────────────────────
class TestPerformance:
@pytest.mark.asyncio
async def test_T23_route_responds_under_200ms_for_5k_symbols(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
"""Route must respond in < 200ms for a repo with 5,000 symbol rows."""
repo = await create_repo(db_session, owner="asuser", slug="t23-perf")
rid = str(repo.repo_id)
chunk_size = 1000
kinds = ["function", "async_function", "class", "method", "async_method"]
for start in range(0, 5_000, chunk_size):
rows = [
{
"repo_id": rid,
"address": f"src/file{i}.py::sym_{i}",
"kind": kinds[i % len(kinds)],
"signature_id": None,
"visibility": "public",
"ref": _REF,
}
for i in range(start, start + chunk_size)
]
await db_session.execute(
pg_insert(MusehubIntelApiSurface)
.values(rows)
.on_conflict_do_nothing()
)
await db_session.commit()
t0 = time.monotonic()
r = await client.get("/asuser/t23-perf/intel/api-surface")
elapsed = time.monotonic() - t0
assert r.status_code == 200
assert elapsed < 0.2, f"Route took {elapsed:.3f}s for 5k symbols (limit: 0.2s)"
@pytest.mark.asyncio
async def test_T24_db_query_uses_repo_index(
self, db_session: AsyncSession
) -> None:
"""SELECT on musehub_intel_api_surface must use ix_intel_api_surface_repo index."""
explain = await db_session.execute(
sa.text(
"EXPLAIN SELECT * FROM musehub_intel_api_surface WHERE repo_id = 'x'"
)
)
plan = " ".join(row[0] for row in explain.all())
assert "ix_intel_api_surface_repo" in plan or "Index" in plan, (
f"Query plan does not use ix_intel_api_surface_repo:\n{plan}"
)
@pytest.mark.asyncio
async def test_T25_batch_upsert_1000_rows_under_500ms(
self, db_session: AsyncSession
) -> None:
"""Direct batch upsert of 1,000 rows must complete in < 500ms wall time."""
repo = await create_repo(db_session, owner="asuser", slug="t25-batch")
rid = str(repo.repo_id)
rows = [
{
"repo_id": rid,
"address": f"src/f{i}.py::fn",
"kind": "function",
"signature_id": None,
"visibility": "public",
"ref": _REF,
}
for i in range(1000)
]
t0 = time.monotonic()
await db_session.execute(
pg_insert(MusehubIntelApiSurface)
.values(rows)
.on_conflict_do_nothing()
)
await db_session.commit()
elapsed = time.monotonic() - t0
assert elapsed < 0.5, f"1000-row batch took {elapsed:.3f}s (limit: 0.5s)"
# ─────────────────────────────────────────────────────────────────────────────
# Layer T7 — Security
# ─────────────────────────────────────────────────────────────────────────────
class TestSecurity:
@pytest.mark.asyncio
async def test_T26_xss_in_address_is_escaped(
self, client: AsyncClient, db_session: AsyncSession
) -> None:
"""XSS payload in address must be HTML-escaped in the response."""
repo = await create_repo(db_session, owner="asuser", slug="t26-xss")
rid = str(repo.repo_id)
xss = ""
await _insert_as_row(db_session, rid, f"src/x.py::{xss[:40]}")
await db_session.commit()
r = await client.get("/asuser/t26-xss/intel/api-surface")
assert r.status_code == 200
assert "