test_symbol_detail_fast_reads.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """TDD spec β fast symbol detail reads via push-time pre-computation. |
| 2 | |
| 3 | Problem |
| 4 | βββββββ |
| 5 | The symbol detail page takes 2-3 s because it computes expensive data at |
| 6 | request time: loading all history rows for an entire file, joining commit |
| 7 | metadata for every entry, and running a large GROUP BY coupling query. |
| 8 | |
| 9 | Solution |
| 10 | ββββββββ |
| 11 | At push time a background job populates three structures: |
| 12 | |
| 13 | 1. ``MusehubSymbolHistoryEntry`` β add ``message`` + ``commit_branch`` |
| 14 | columns so the timeline read needs no join to ``MusehubCommit``. |
| 15 | |
| 16 | 2. ``MusehubSymbolVitals`` β new table: one pre-computed row per |
| 17 | symbol with first_introduced, change_count, version_count, op breakdown. |
| 18 | Eliminates loading the full history to derive vitals at request time. |
| 19 | |
| 20 | 3. ``MusehubSymbolCoupling`` β new table: one row per (symbol, co_symbol) |
| 21 | pair with shared_commits count. Replaces the request-time |
| 22 | GROUP BY across a large commit_id IN (...) set. |
| 23 | |
| 24 | At page load the route becomes: |
| 25 | SELECT * FROM musehub_symbol_history_entries WHERE repo_id=X AND address=Y |
| 26 | SELECT * FROM musehub_symbol_vitals WHERE repo_id=X AND address=Y |
| 27 | SELECT * FROM musehub_symbol_intel WHERE repo_id=X AND address=Y |
| 28 | SELECT * FROM musehub_symbol_coupling WHERE repo_id=X AND address=Y LIMIT N OFFSET M |
| 29 | + 4 small per-table intel reads (type, blast_risk, dead, api) |
| 30 | |
| 31 | Tier breakdown |
| 32 | ββββββββββββββ |
| 33 | D1xx Schema β new columns / tables exist and have correct types |
| 34 | D2xx Indexer β background job populates data correctly at push time |
| 35 | D3xx Route β detail page uses pre-computed data (no heavy queries) |
| 36 | D4xx Edge β empty history, single entry, rename/lineage, rebuild idempotency |
| 37 | """ |
| 38 | from __future__ import annotations |
| 39 | |
| 40 | import datetime as _dt |
| 41 | import secrets |
| 42 | from datetime import timezone |
| 43 | |
| 44 | import pytest |
| 45 | from httpx import AsyncClient |
| 46 | from sqlalchemy import select, text |
| 47 | from sqlalchemy.ext.asyncio import AsyncSession |
| 48 | |
| 49 | from musehub.db.musehub_intel_models import MusehubSymbolCoupling, MusehubSymbolHistoryEntry, MusehubSymbolVitals |
| 50 | from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef |
| 51 | from muse.core.types import blob_id, long_id |
| 52 | from tests.factories import create_repo |
| 53 | |
| 54 | |
| 55 | # --------------------------------------------------------------------------- |
| 56 | # Helpers |
| 57 | # --------------------------------------------------------------------------- |
| 58 | |
| 59 | def _now(offset_days: int = 0) -> _dt.datetime: |
| 60 | return _dt.datetime.now(tz=timezone.utc) + _dt.timedelta(days=offset_days) |
| 61 | |
| 62 | |
| 63 | def _cid() -> str: |
| 64 | return blob_id(secrets.token_bytes(32)) |
| 65 | |
| 66 | |
| 67 | def _lid() -> str: |
| 68 | return long_id(secrets.token_hex(32)) |
| 69 | |
| 70 | |
| 71 | async def _insert_history( |
| 72 | session: AsyncSession, |
| 73 | repo_id: str, |
| 74 | address: str, |
| 75 | commit_id: str, |
| 76 | op: str = "insert", |
| 77 | content_id: str | None = None, |
| 78 | message: str = "feat: test", |
| 79 | commit_branch: str = "dev", |
| 80 | committed_at: _dt.datetime | None = None, |
| 81 | author: str = "gabriel", |
| 82 | ) -> MusehubSymbolHistoryEntry: |
| 83 | entry = MusehubSymbolHistoryEntry( |
| 84 | repo_id=repo_id, |
| 85 | address=address, |
| 86 | commit_id=commit_id, |
| 87 | op=op, |
| 88 | content_id=content_id or _cid(), |
| 89 | message=message, |
| 90 | commit_branch=commit_branch, |
| 91 | committed_at=committed_at or _now(), |
| 92 | author=author, |
| 93 | ) |
| 94 | session.add(entry) |
| 95 | await session.flush() |
| 96 | return entry |
| 97 | |
| 98 | |
| 99 | async def _insert_vitals( |
| 100 | session: AsyncSession, |
| 101 | repo_id: str, |
| 102 | address: str, |
| 103 | *, |
| 104 | first_introduced: _dt.datetime | None = None, |
| 105 | change_count: int = 1, |
| 106 | version_count: int = 1, |
| 107 | op_add: int = 1, |
| 108 | op_modify: int = 0, |
| 109 | op_delete: int = 0, |
| 110 | op_move: int = 0, |
| 111 | ) -> "MusehubSymbolVitals": |
| 112 | row = MusehubSymbolVitals( |
| 113 | repo_id=repo_id, |
| 114 | address=address, |
| 115 | first_introduced=first_introduced or _now(), |
| 116 | change_count=change_count, |
| 117 | version_count=version_count, |
| 118 | op_add=op_add, |
| 119 | op_modify=op_modify, |
| 120 | op_delete=op_delete, |
| 121 | op_move=op_move, |
| 122 | ) |
| 123 | session.add(row) |
| 124 | await session.flush() |
| 125 | return row |
| 126 | |
| 127 | |
| 128 | async def _insert_coupling( |
| 129 | session: AsyncSession, |
| 130 | repo_id: str, |
| 131 | address: str, |
| 132 | co_address: str, |
| 133 | shared_commits: int, |
| 134 | ) -> "MusehubSymbolCoupling": |
| 135 | row = MusehubSymbolCoupling( |
| 136 | repo_id=repo_id, |
| 137 | address=address, |
| 138 | co_address=co_address, |
| 139 | shared_commits=shared_commits, |
| 140 | ) |
| 141 | session.add(row) |
| 142 | await session.flush() |
| 143 | return row |
| 144 | |
| 145 | |
| 146 | # --------------------------------------------------------------------------- |
| 147 | # D1xx β Schema: new columns / tables exist |
| 148 | # --------------------------------------------------------------------------- |
| 149 | |
| 150 | class TestHistoryEntrySchema: |
| 151 | """D101βD103: MusehubSymbolHistoryEntry has message + commit_branch.""" |
| 152 | |
| 153 | async def test_D101_message_column_exists(self, db_session: AsyncSession) -> None: |
| 154 | """D101: MusehubSymbolHistoryEntry.message column is present.""" |
| 155 | repo = await create_repo(db_session, owner="gabriel") |
| 156 | cid = _lid() |
| 157 | entry = MusehubSymbolHistoryEntry( |
| 158 | repo_id=repo.repo_id, |
| 159 | address="src/foo.py::bar", |
| 160 | commit_id=cid, |
| 161 | op="insert", |
| 162 | content_id=_cid(), |
| 163 | message="feat: add bar", |
| 164 | commit_branch="dev", |
| 165 | committed_at=_now(), |
| 166 | ) |
| 167 | db_session.add(entry) |
| 168 | await db_session.flush() |
| 169 | fetched = (await db_session.execute( |
| 170 | select(MusehubSymbolHistoryEntry).where( |
| 171 | MusehubSymbolHistoryEntry.repo_id == repo.repo_id, |
| 172 | MusehubSymbolHistoryEntry.address == "src/foo.py::bar", |
| 173 | ) |
| 174 | )).scalar_one() |
| 175 | assert fetched.message == "feat: add bar" |
| 176 | |
| 177 | async def test_D102_commit_branch_column_exists(self, db_session: AsyncSession) -> None: |
| 178 | """D102: MusehubSymbolHistoryEntry.commit_branch column is present.""" |
| 179 | repo = await create_repo(db_session, owner="gabriel") |
| 180 | cid = _lid() |
| 181 | entry = MusehubSymbolHistoryEntry( |
| 182 | repo_id=repo.repo_id, |
| 183 | address="src/foo.py::bar", |
| 184 | commit_id=cid, |
| 185 | op="insert", |
| 186 | content_id=_cid(), |
| 187 | message="feat: x", |
| 188 | commit_branch="feat/my-thing", |
| 189 | committed_at=_now(), |
| 190 | ) |
| 191 | db_session.add(entry) |
| 192 | await db_session.flush() |
| 193 | fetched = (await db_session.execute( |
| 194 | select(MusehubSymbolHistoryEntry).where( |
| 195 | MusehubSymbolHistoryEntry.repo_id == repo.repo_id, |
| 196 | MusehubSymbolHistoryEntry.address == "src/foo.py::bar", |
| 197 | ) |
| 198 | )).scalar_one() |
| 199 | assert fetched.commit_branch == "feat/my-thing" |
| 200 | |
| 201 | async def test_D103_message_and_branch_nullable(self, db_session: AsyncSession) -> None: |
| 202 | """D103: message and commit_branch accept None (backward compat for old rows).""" |
| 203 | repo = await create_repo(db_session, owner="gabriel") |
| 204 | entry = MusehubSymbolHistoryEntry( |
| 205 | repo_id=repo.repo_id, |
| 206 | address="src/foo.py::baz", |
| 207 | commit_id=_lid(), |
| 208 | op="insert", |
| 209 | content_id=_cid(), |
| 210 | message=None, |
| 211 | commit_branch=None, |
| 212 | committed_at=_now(), |
| 213 | ) |
| 214 | db_session.add(entry) |
| 215 | await db_session.flush() |
| 216 | fetched = (await db_session.execute( |
| 217 | select(MusehubSymbolHistoryEntry).where( |
| 218 | MusehubSymbolHistoryEntry.address == "src/foo.py::baz", |
| 219 | ) |
| 220 | )).scalar_one() |
| 221 | assert fetched.message is None |
| 222 | assert fetched.commit_branch is None |
| 223 | |
| 224 | |
| 225 | class TestSymbolVitalsSchema: |
| 226 | """D110βD116: MusehubSymbolVitals table exists with correct columns.""" |
| 227 | |
| 228 | async def test_D110_table_exists(self, db_session: AsyncSession) -> None: |
| 229 | """D110: MusehubSymbolVitals ORM class is importable and maps to a table.""" |
| 230 | assert MusehubSymbolVitals is not None |
| 231 | |
| 232 | async def test_D111_insert_and_fetch(self, db_session: AsyncSession) -> None: |
| 233 | """D111: can insert and retrieve a vitals row.""" |
| 234 | repo = await create_repo(db_session, owner="gabriel") |
| 235 | introduced = _now(offset_days=-30) |
| 236 | row = await _insert_vitals( |
| 237 | db_session, repo.repo_id, "src/core.py::process", |
| 238 | first_introduced=introduced, |
| 239 | change_count=42, |
| 240 | version_count=7, |
| 241 | op_add=1, |
| 242 | op_modify=40, |
| 243 | op_delete=0, |
| 244 | op_move=1, |
| 245 | ) |
| 246 | fetched = (await db_session.execute( |
| 247 | select(MusehubSymbolVitals).where( |
| 248 | MusehubSymbolVitals.repo_id == repo.repo_id, |
| 249 | MusehubSymbolVitals.address == "src/core.py::process", |
| 250 | ) |
| 251 | )).scalar_one() |
| 252 | assert fetched.change_count == 42 |
| 253 | assert fetched.version_count == 7 |
| 254 | assert fetched.op_add == 1 |
| 255 | assert fetched.op_modify == 40 |
| 256 | assert fetched.op_delete == 0 |
| 257 | assert fetched.op_move == 1 |
| 258 | |
| 259 | async def test_D112_first_introduced_is_datetime(self, db_session: AsyncSession) -> None: |
| 260 | """D112: first_introduced stores a timezone-aware datetime.""" |
| 261 | repo = await create_repo(db_session, owner="gabriel") |
| 262 | ts = _now(offset_days=-10) |
| 263 | await _insert_vitals(db_session, repo.repo_id, "src/a.py::fn", first_introduced=ts) |
| 264 | fetched = (await db_session.execute( |
| 265 | select(MusehubSymbolVitals).where( |
| 266 | MusehubSymbolVitals.repo_id == repo.repo_id, |
| 267 | MusehubSymbolVitals.address == "src/a.py::fn", |
| 268 | ) |
| 269 | )).scalar_one() |
| 270 | assert fetched.first_introduced is not None |
| 271 | assert fetched.first_introduced.tzinfo is not None |
| 272 | |
| 273 | async def test_D113_primary_key_is_repo_and_address(self, db_session: AsyncSession) -> None: |
| 274 | """D113: upsert on (repo_id, address) replaces the row.""" |
| 275 | repo = await create_repo(db_session, owner="gabriel") |
| 276 | await _insert_vitals(db_session, repo.repo_id, "src/a.py::fn", change_count=5) |
| 277 | await db_session.commit() |
| 278 | |
| 279 | # Re-insert with updated count |
| 280 | row2 = MusehubSymbolVitals( |
| 281 | repo_id=repo.repo_id, |
| 282 | address="src/a.py::fn", |
| 283 | first_introduced=_now(), |
| 284 | change_count=10, |
| 285 | version_count=2, |
| 286 | op_add=1, op_modify=9, op_delete=0, op_move=0, |
| 287 | ) |
| 288 | from sqlalchemy.dialects.postgresql import insert as pg_insert |
| 289 | stmt = pg_insert(MusehubSymbolVitals).values( |
| 290 | repo_id=row2.repo_id, |
| 291 | address=row2.address, |
| 292 | first_introduced=row2.first_introduced, |
| 293 | change_count=row2.change_count, |
| 294 | version_count=row2.version_count, |
| 295 | op_add=row2.op_add, |
| 296 | op_modify=row2.op_modify, |
| 297 | op_delete=row2.op_delete, |
| 298 | op_move=row2.op_move, |
| 299 | ).on_conflict_do_update( |
| 300 | index_elements=["repo_id", "address"], |
| 301 | set_={"change_count": row2.change_count, "version_count": row2.version_count}, |
| 302 | ) |
| 303 | await db_session.execute(stmt) |
| 304 | await db_session.commit() |
| 305 | |
| 306 | rows = (await db_session.execute( |
| 307 | select(MusehubSymbolVitals).where( |
| 308 | MusehubSymbolVitals.repo_id == repo.repo_id, |
| 309 | MusehubSymbolVitals.address == "src/a.py::fn", |
| 310 | ) |
| 311 | )).scalars().all() |
| 312 | assert len(rows) == 1 |
| 313 | assert rows[0].change_count == 10 |
| 314 | |
| 315 | async def test_D114_cascade_delete_with_repo(self, db_session: AsyncSession) -> None: |
| 316 | """D114: vitals rows are deleted when the repo is deleted.""" |
| 317 | repo = await create_repo(db_session, owner="gabriel") |
| 318 | await _insert_vitals(db_session, repo.repo_id, "src/a.py::fn") |
| 319 | await db_session.commit() |
| 320 | await db_session.delete(repo) |
| 321 | await db_session.commit() |
| 322 | rows = (await db_session.execute( |
| 323 | select(MusehubSymbolVitals).where( |
| 324 | MusehubSymbolVitals.repo_id == repo.repo_id, |
| 325 | ) |
| 326 | )).scalars().all() |
| 327 | assert rows == [] |
| 328 | |
| 329 | |
| 330 | class TestSymbolCouplingSchema: |
| 331 | """D120βD126: MusehubSymbolCoupling table exists with correct columns.""" |
| 332 | |
| 333 | async def test_D120_table_exists(self, db_session: AsyncSession) -> None: |
| 334 | """D120: MusehubSymbolCoupling ORM class is importable.""" |
| 335 | assert MusehubSymbolCoupling is not None |
| 336 | |
| 337 | async def test_D121_insert_and_fetch(self, db_session: AsyncSession) -> None: |
| 338 | """D121: can insert and retrieve a coupling row.""" |
| 339 | repo = await create_repo(db_session, owner="gabriel") |
| 340 | row = await _insert_coupling( |
| 341 | db_session, repo.repo_id, |
| 342 | "src/a.py::fn", "src/b.py::helper", shared_commits=12, |
| 343 | ) |
| 344 | fetched = (await db_session.execute( |
| 345 | select(MusehubSymbolCoupling).where( |
| 346 | MusehubSymbolCoupling.repo_id == repo.repo_id, |
| 347 | MusehubSymbolCoupling.address == "src/a.py::fn", |
| 348 | ) |
| 349 | )).scalar_one() |
| 350 | assert fetched.co_address == "src/b.py::helper" |
| 351 | assert fetched.shared_commits == 12 |
| 352 | |
| 353 | async def test_D122_ordered_by_shared_commits_desc(self, db_session: AsyncSession) -> None: |
| 354 | """D122: coupling rows can be fetched ordered by shared_commits descending.""" |
| 355 | repo = await create_repo(db_session, owner="gabriel") |
| 356 | await _insert_coupling(db_session, repo.repo_id, "src/a.py::fn", "src/b.py::b", 3) |
| 357 | await _insert_coupling(db_session, repo.repo_id, "src/a.py::fn", "src/c.py::c", 10) |
| 358 | await _insert_coupling(db_session, repo.repo_id, "src/a.py::fn", "src/d.py::d", 7) |
| 359 | await db_session.flush() |
| 360 | |
| 361 | rows = (await db_session.execute( |
| 362 | select(MusehubSymbolCoupling) |
| 363 | .where( |
| 364 | MusehubSymbolCoupling.repo_id == repo.repo_id, |
| 365 | MusehubSymbolCoupling.address == "src/a.py::fn", |
| 366 | ) |
| 367 | .order_by(MusehubSymbolCoupling.shared_commits.desc()) |
| 368 | )).scalars().all() |
| 369 | counts = [r.shared_commits for r in rows] |
| 370 | assert counts == sorted(counts, reverse=True) |
| 371 | assert counts[0] == 10 |
| 372 | |
| 373 | async def test_D123_pagination_with_limit_offset(self, db_session: AsyncSession) -> None: |
| 374 | """D123: coupling supports limit/offset for cursor pagination.""" |
| 375 | repo = await create_repo(db_session, owner="gabriel") |
| 376 | for i in range(20): |
| 377 | await _insert_coupling( |
| 378 | db_session, repo.repo_id, |
| 379 | "src/a.py::fn", f"src/x{i}.py::sym", shared_commits=20 - i, |
| 380 | ) |
| 381 | await db_session.flush() |
| 382 | |
| 383 | page1 = (await db_session.execute( |
| 384 | select(MusehubSymbolCoupling) |
| 385 | .where( |
| 386 | MusehubSymbolCoupling.repo_id == repo.repo_id, |
| 387 | MusehubSymbolCoupling.address == "src/a.py::fn", |
| 388 | ) |
| 389 | .order_by(MusehubSymbolCoupling.shared_commits.desc()) |
| 390 | .limit(15).offset(0) |
| 391 | )).scalars().all() |
| 392 | page2 = (await db_session.execute( |
| 393 | select(MusehubSymbolCoupling) |
| 394 | .where( |
| 395 | MusehubSymbolCoupling.repo_id == repo.repo_id, |
| 396 | MusehubSymbolCoupling.address == "src/a.py::fn", |
| 397 | ) |
| 398 | .order_by(MusehubSymbolCoupling.shared_commits.desc()) |
| 399 | .limit(15).offset(15) |
| 400 | )).scalars().all() |
| 401 | |
| 402 | assert len(page1) == 15 |
| 403 | assert len(page2) == 5 |
| 404 | all_addrs = {r.co_address for r in page1} | {r.co_address for r in page2} |
| 405 | assert len(all_addrs) == 20 |
| 406 | |
| 407 | async def test_D124_cascade_delete_with_repo(self, db_session: AsyncSession) -> None: |
| 408 | """D124: coupling rows are deleted when the repo is deleted.""" |
| 409 | repo = await create_repo(db_session, owner="gabriel") |
| 410 | await _insert_coupling(db_session, repo.repo_id, "src/a.py::fn", "src/b.py::g", 5) |
| 411 | await db_session.commit() |
| 412 | await db_session.delete(repo) |
| 413 | await db_session.commit() |
| 414 | rows = (await db_session.execute( |
| 415 | select(MusehubSymbolCoupling).where( |
| 416 | MusehubSymbolCoupling.repo_id == repo.repo_id, |
| 417 | ) |
| 418 | )).scalars().all() |
| 419 | assert rows == [] |
| 420 | |
| 421 | async def test_D125_primary_key_is_repo_address_co_address(self, db_session: AsyncSession) -> None: |
| 422 | """D125: (repo_id, address, co_address) is the primary key β no duplicates.""" |
| 423 | repo = await create_repo(db_session, owner="gabriel") |
| 424 | await _insert_coupling(db_session, repo.repo_id, "src/a.py::fn", "src/b.py::g", 5) |
| 425 | await db_session.commit() |
| 426 | |
| 427 | from sqlalchemy.dialects.postgresql import insert as pg_insert |
| 428 | stmt = pg_insert(MusehubSymbolCoupling).values( |
| 429 | repo_id=repo.repo_id, |
| 430 | address="src/a.py::fn", |
| 431 | co_address="src/b.py::g", |
| 432 | shared_commits=99, |
| 433 | ).on_conflict_do_update( |
| 434 | index_elements=["repo_id", "address", "co_address"], |
| 435 | set_={"shared_commits": 99}, |
| 436 | ) |
| 437 | await db_session.execute(stmt) |
| 438 | await db_session.commit() |
| 439 | |
| 440 | rows = (await db_session.execute( |
| 441 | select(MusehubSymbolCoupling).where( |
| 442 | MusehubSymbolCoupling.repo_id == repo.repo_id, |
| 443 | MusehubSymbolCoupling.address == "src/a.py::fn", |
| 444 | ) |
| 445 | )).scalars().all() |
| 446 | assert len(rows) == 1 |
| 447 | assert rows[0].shared_commits == 99 |
| 448 | |
| 449 | |
| 450 | # --------------------------------------------------------------------------- |
| 451 | # D2xx β Indexer: background job populates data at push time |
| 452 | # --------------------------------------------------------------------------- |
| 453 | |
| 454 | class TestIndexerPopulatesVitals: |
| 455 | """D201βD207: build_symbol_index writes MusehubSymbolVitals rows.""" |
| 456 | |
| 457 | async def test_D201_vitals_written_after_index_build(self, db_session: AsyncSession) -> None: |
| 458 | """D201: after build_symbol_index, a vitals row exists for each indexed symbol.""" |
| 459 | from musehub.services.musehub_symbol_indexer import build_symbol_index |
| 460 | |
| 461 | repo = await create_repo(db_session, owner="gabriel") |
| 462 | cid = _lid() |
| 463 | commit = MusehubCommit( |
| 464 | commit_id=cid, |
| 465 | branch="dev", |
| 466 | parent_ids=[], |
| 467 | message="feat: add fn", |
| 468 | author="gabriel", |
| 469 | timestamp=_now(), |
| 470 | structured_delta={"ops": [ |
| 471 | {"address": "src/core.py::process", "op": "insert", "content_id": _cid()}, |
| 472 | ]}, |
| 473 | ) |
| 474 | db_session.add(commit) |
| 475 | db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) |
| 476 | await db_session.flush() |
| 477 | |
| 478 | await build_symbol_index(db_session, repo.repo_id, cid) |
| 479 | await db_session.flush() |
| 480 | |
| 481 | row = (await db_session.execute( |
| 482 | select(MusehubSymbolVitals).where( |
| 483 | MusehubSymbolVitals.repo_id == repo.repo_id, |
| 484 | MusehubSymbolVitals.address == "src/core.py::process", |
| 485 | ) |
| 486 | )).scalar_one_or_none() |
| 487 | assert row is not None |
| 488 | assert row.change_count == 1 |
| 489 | assert row.op_add == 1 |
| 490 | |
| 491 | async def test_D202_vitals_change_count_increments_on_rebuild(self, db_session: AsyncSession) -> None: |
| 492 | """D202: change_count reflects all commits for the symbol after rebuild.""" |
| 493 | from musehub.services.musehub_symbol_indexer import build_symbol_index |
| 494 | |
| 495 | repo = await create_repo(db_session, owner="gabriel") |
| 496 | content = _cid() |
| 497 | prev_id: str | None = None |
| 498 | last_cid = "" |
| 499 | for i in range(5): |
| 500 | cid = _lid() |
| 501 | last_cid = cid |
| 502 | commit = MusehubCommit( |
| 503 | commit_id=cid, |
| 504 | branch="dev", |
| 505 | parent_ids=[prev_id] if prev_id else [], |
| 506 | message=f"feat: change {i}", |
| 507 | author="gabriel", |
| 508 | timestamp=_now(offset_days=i), |
| 509 | structured_delta={"ops": [ |
| 510 | {"address": "src/core.py::process", "op": "replace" if i else "insert", "content_id": content}, |
| 511 | ]}, |
| 512 | ) |
| 513 | db_session.add(commit) |
| 514 | db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) |
| 515 | prev_id = cid |
| 516 | await db_session.flush() |
| 517 | |
| 518 | await build_symbol_index(db_session, repo.repo_id, last_cid) |
| 519 | await db_session.flush() |
| 520 | |
| 521 | row = (await db_session.execute( |
| 522 | select(MusehubSymbolVitals).where( |
| 523 | MusehubSymbolVitals.repo_id == repo.repo_id, |
| 524 | MusehubSymbolVitals.address == "src/core.py::process", |
| 525 | ) |
| 526 | )).scalar_one_or_none() |
| 527 | assert row is not None |
| 528 | assert row.change_count == 5 |
| 529 | |
| 530 | async def test_D203_vitals_first_introduced_is_earliest_commit(self, db_session: AsyncSession) -> None: |
| 531 | """D203: first_introduced matches the committed_at of the symbol's first entry.""" |
| 532 | from musehub.services.musehub_symbol_indexer import build_symbol_index |
| 533 | |
| 534 | repo = await create_repo(db_session, owner="gabriel") |
| 535 | first_ts = _now(offset_days=-10) |
| 536 | prev_id: str | None = None |
| 537 | last_cid = "" |
| 538 | for i, ts in enumerate([first_ts, _now(offset_days=-5), _now()]): |
| 539 | cid = _lid() |
| 540 | last_cid = cid |
| 541 | commit = MusehubCommit( |
| 542 | commit_id=cid, |
| 543 | branch="dev", |
| 544 | parent_ids=[prev_id] if prev_id else [], |
| 545 | message="change", |
| 546 | author="gabriel", |
| 547 | timestamp=ts, |
| 548 | structured_delta={"ops": [ |
| 549 | {"address": "src/a.py::fn", "op": "insert" if i == 0 else "replace", "content_id": _cid()}, |
| 550 | ]}, |
| 551 | ) |
| 552 | db_session.add(commit) |
| 553 | db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) |
| 554 | prev_id = cid |
| 555 | await db_session.flush() |
| 556 | |
| 557 | await build_symbol_index(db_session, repo.repo_id, last_cid) |
| 558 | await db_session.flush() |
| 559 | |
| 560 | row = (await db_session.execute( |
| 561 | select(MusehubSymbolVitals).where( |
| 562 | MusehubSymbolVitals.repo_id == repo.repo_id, |
| 563 | MusehubSymbolVitals.address == "src/a.py::fn", |
| 564 | ) |
| 565 | )).scalar_one_or_none() |
| 566 | assert row is not None |
| 567 | assert abs((row.first_introduced - first_ts).total_seconds()) < 2 |
| 568 | |
| 569 | |
| 570 | class TestIndexerPopulatesCoupling: |
| 571 | """D210βD215: build_symbol_index writes MusehubSymbolCoupling rows.""" |
| 572 | |
| 573 | async def test_D210_coupling_written_for_co_changed_symbols(self, db_session: AsyncSession) -> None: |
| 574 | """D210: symbols changed in the same commit get coupling rows.""" |
| 575 | from musehub.services.musehub_symbol_indexer import build_symbol_index, backfill_coupling |
| 576 | |
| 577 | repo = await create_repo(db_session, owner="gabriel") |
| 578 | cid = _lid() |
| 579 | commit = MusehubCommit( |
| 580 | commit_id=cid, |
| 581 | branch="dev", |
| 582 | parent_ids=[], |
| 583 | message="feat: big change", |
| 584 | author="gabriel", |
| 585 | timestamp=_now(), |
| 586 | structured_delta={"ops": [ |
| 587 | {"address": "src/a.py::fn_a", "op": "insert", "content_id": _cid()}, |
| 588 | {"address": "src/b.py::fn_b", "op": "insert", "content_id": _cid()}, |
| 589 | ]}, |
| 590 | ) |
| 591 | db_session.add(commit) |
| 592 | db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) |
| 593 | await db_session.flush() |
| 594 | |
| 595 | await build_symbol_index(db_session, repo.repo_id, cid) |
| 596 | await backfill_coupling(db_session, repo.repo_id, min_shared=1) |
| 597 | await db_session.flush() |
| 598 | |
| 599 | coupling_a = (await db_session.execute( |
| 600 | select(MusehubSymbolCoupling).where( |
| 601 | MusehubSymbolCoupling.repo_id == repo.repo_id, |
| 602 | MusehubSymbolCoupling.address == "src/a.py::fn_a", |
| 603 | MusehubSymbolCoupling.co_address == "src/b.py::fn_b", |
| 604 | ) |
| 605 | )).scalar_one_or_none() |
| 606 | assert coupling_a is not None |
| 607 | assert coupling_a.shared_commits == 1 |
| 608 | |
| 609 | async def test_D211_coupling_is_symmetric(self, db_session: AsyncSession) -> None: |
| 610 | """D211: if A couples with B, there is also a row for BβA.""" |
| 611 | from musehub.services.musehub_symbol_indexer import build_symbol_index, backfill_coupling |
| 612 | |
| 613 | repo = await create_repo(db_session, owner="gabriel") |
| 614 | cid = _lid() |
| 615 | commit = MusehubCommit( |
| 616 | commit_id=cid, |
| 617 | branch="dev", |
| 618 | parent_ids=[], |
| 619 | message="change", |
| 620 | author="gabriel", |
| 621 | timestamp=_now(), |
| 622 | structured_delta={"ops": [ |
| 623 | {"address": "src/a.py::fn_a", "op": "insert", "content_id": _cid()}, |
| 624 | {"address": "src/b.py::fn_b", "op": "insert", "content_id": _cid()}, |
| 625 | ]}, |
| 626 | ) |
| 627 | db_session.add(commit) |
| 628 | db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) |
| 629 | await db_session.flush() |
| 630 | |
| 631 | await build_symbol_index(db_session, repo.repo_id, cid) |
| 632 | await backfill_coupling(db_session, repo.repo_id, min_shared=1) |
| 633 | await db_session.flush() |
| 634 | |
| 635 | b_to_a = (await db_session.execute( |
| 636 | select(MusehubSymbolCoupling).where( |
| 637 | MusehubSymbolCoupling.repo_id == repo.repo_id, |
| 638 | MusehubSymbolCoupling.address == "src/b.py::fn_b", |
| 639 | MusehubSymbolCoupling.co_address == "src/a.py::fn_a", |
| 640 | ) |
| 641 | )).scalar_one_or_none() |
| 642 | assert b_to_a is not None |
| 643 | |
| 644 | async def test_D212_coupling_count_accumulates_across_commits(self, db_session: AsyncSession) -> None: |
| 645 | """D212: shared_commits increments each time both symbols appear in a commit.""" |
| 646 | from musehub.services.musehub_symbol_indexer import build_symbol_index, backfill_coupling |
| 647 | |
| 648 | repo = await create_repo(db_session, owner="gabriel") |
| 649 | last_cid = "" |
| 650 | prev_id: str | None = None |
| 651 | for i in range(3): |
| 652 | cid = _lid() |
| 653 | last_cid = cid |
| 654 | commit = MusehubCommit( |
| 655 | commit_id=cid, |
| 656 | branch="dev", |
| 657 | parent_ids=[prev_id] if prev_id else [], |
| 658 | message=f"change {i}", |
| 659 | author="gabriel", |
| 660 | timestamp=_now(offset_days=i), |
| 661 | structured_delta={"ops": [ |
| 662 | {"address": "src/a.py::fn_a", "op": "insert" if i == 0 else "replace", "content_id": _cid()}, |
| 663 | {"address": "src/b.py::fn_b", "op": "insert" if i == 0 else "replace", "content_id": _cid()}, |
| 664 | ]}, |
| 665 | ) |
| 666 | db_session.add(commit) |
| 667 | db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) |
| 668 | prev_id = cid |
| 669 | await db_session.flush() |
| 670 | |
| 671 | await build_symbol_index(db_session, repo.repo_id, last_cid) |
| 672 | await backfill_coupling(db_session, repo.repo_id, min_shared=1) |
| 673 | await db_session.flush() |
| 674 | |
| 675 | row = (await db_session.execute( |
| 676 | select(MusehubSymbolCoupling).where( |
| 677 | MusehubSymbolCoupling.repo_id == repo.repo_id, |
| 678 | MusehubSymbolCoupling.address == "src/a.py::fn_a", |
| 679 | MusehubSymbolCoupling.co_address == "src/b.py::fn_b", |
| 680 | ) |
| 681 | )).scalar_one_or_none() |
| 682 | assert row is not None |
| 683 | assert row.shared_commits == 3 |
| 684 | |
| 685 | async def test_D213_rebuild_is_idempotent(self, db_session: AsyncSession) -> None: |
| 686 | """D213: running build_symbol_index twice produces the same coupling counts.""" |
| 687 | from musehub.services.musehub_symbol_indexer import build_symbol_index, backfill_coupling |
| 688 | |
| 689 | repo = await create_repo(db_session, owner="gabriel") |
| 690 | cid = _lid() |
| 691 | commit = MusehubCommit( |
| 692 | commit_id=cid, |
| 693 | branch="dev", |
| 694 | parent_ids=[], |
| 695 | message="change", |
| 696 | author="gabriel", |
| 697 | timestamp=_now(), |
| 698 | structured_delta={"ops": [ |
| 699 | {"address": "src/a.py::fn_a", "op": "insert", "content_id": _cid()}, |
| 700 | {"address": "src/b.py::fn_b", "op": "insert", "content_id": _cid()}, |
| 701 | ]}, |
| 702 | ) |
| 703 | db_session.add(commit) |
| 704 | db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) |
| 705 | await db_session.flush() |
| 706 | |
| 707 | for _ in range(2): |
| 708 | await build_symbol_index(db_session, repo.repo_id, cid) |
| 709 | await backfill_coupling(db_session, repo.repo_id, min_shared=1) |
| 710 | await db_session.flush() |
| 711 | |
| 712 | rows = (await db_session.execute( |
| 713 | select(MusehubSymbolCoupling).where( |
| 714 | MusehubSymbolCoupling.repo_id == repo.repo_id, |
| 715 | MusehubSymbolCoupling.address == "src/a.py::fn_a", |
| 716 | MusehubSymbolCoupling.co_address == "src/b.py::fn_b", |
| 717 | ) |
| 718 | )).scalars().all() |
| 719 | assert len(rows) == 1 |
| 720 | assert rows[0].shared_commits == 1 |
| 721 | |
| 722 | |
| 723 | class TestIndexerDenormalizesCommitMessage: |
| 724 | """D220βD223: build_symbol_index stores message + commit_branch on history entries.""" |
| 725 | |
| 726 | async def test_D220_history_entry_has_message(self, db_session: AsyncSession) -> None: |
| 727 | """D220: history entry written by indexer carries the commit message.""" |
| 728 | from musehub.services.musehub_symbol_indexer import build_symbol_index |
| 729 | |
| 730 | repo = await create_repo(db_session, owner="gabriel") |
| 731 | cid = _lid() |
| 732 | commit = MusehubCommit( |
| 733 | commit_id=cid, |
| 734 | branch="dev", |
| 735 | parent_ids=[], |
| 736 | message="feat: add important fn", |
| 737 | author="gabriel", |
| 738 | timestamp=_now(), |
| 739 | structured_delta={"ops": [ |
| 740 | {"address": "src/core.py::important_fn", "op": "insert", "content_id": _cid()}, |
| 741 | ]}, |
| 742 | ) |
| 743 | db_session.add(commit) |
| 744 | db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) |
| 745 | await db_session.flush() |
| 746 | |
| 747 | await build_symbol_index(db_session, repo.repo_id, cid) |
| 748 | await db_session.flush() |
| 749 | |
| 750 | entry = (await db_session.execute( |
| 751 | select(MusehubSymbolHistoryEntry).where( |
| 752 | MusehubSymbolHistoryEntry.repo_id == repo.repo_id, |
| 753 | MusehubSymbolHistoryEntry.address == "src/core.py::important_fn", |
| 754 | ) |
| 755 | )).scalar_one_or_none() |
| 756 | assert entry is not None |
| 757 | assert entry.message == "feat: add important fn" |
| 758 | |
| 759 | async def test_D221_history_entry_has_commit_branch(self, db_session: AsyncSession) -> None: |
| 760 | """D221: history entry written by indexer carries the commit_branch.""" |
| 761 | from musehub.services.musehub_symbol_indexer import build_symbol_index |
| 762 | |
| 763 | repo = await create_repo(db_session, owner="gabriel") |
| 764 | cid = _lid() |
| 765 | commit = MusehubCommit( |
| 766 | commit_id=cid, |
| 767 | branch="feat/audio", |
| 768 | parent_ids=[], |
| 769 | message="feat: audio fn", |
| 770 | author="gabriel", |
| 771 | timestamp=_now(), |
| 772 | structured_delta={"ops": [ |
| 773 | {"address": "src/audio.py::encode", "op": "insert", "content_id": _cid()}, |
| 774 | ]}, |
| 775 | ) |
| 776 | db_session.add(commit) |
| 777 | db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) |
| 778 | await db_session.flush() |
| 779 | |
| 780 | await build_symbol_index(db_session, repo.repo_id, cid) |
| 781 | await db_session.flush() |
| 782 | |
| 783 | entry = (await db_session.execute( |
| 784 | select(MusehubSymbolHistoryEntry).where( |
| 785 | MusehubSymbolHistoryEntry.repo_id == repo.repo_id, |
| 786 | MusehubSymbolHistoryEntry.address == "src/audio.py::encode", |
| 787 | ) |
| 788 | )).scalar_one_or_none() |
| 789 | assert entry is not None |
| 790 | assert entry.commit_branch == "feat/audio" |
| 791 | |
| 792 | |
| 793 | # --------------------------------------------------------------------------- |
| 794 | # D3xx β Route: detail page reads pre-computed data, no heavy queries |
| 795 | # --------------------------------------------------------------------------- |
| 796 | |
| 797 | class TestRouteUsesPrecomputedData: |
| 798 | """D301βD305: symbol detail route reads vitals + coupling tables.""" |
| 799 | |
| 800 | async def test_D301_page_renders_change_count_from_vitals( |
| 801 | self, db_session: AsyncSession, client: AsyncClient |
| 802 | ) -> None: |
| 803 | """D301: change_count on the page comes from MusehubSymbolVitals, not history scan.""" |
| 804 | repo = await create_repo(db_session, owner="gabriel", slug="myrepo") |
| 805 | await db_session.commit() |
| 806 | |
| 807 | address = "src/core.py::process" |
| 808 | cid = _lid() |
| 809 | entry = MusehubSymbolHistoryEntry( |
| 810 | repo_id=repo.repo_id, |
| 811 | address=address, |
| 812 | commit_id=cid, |
| 813 | op="insert", |
| 814 | content_id=_cid(), |
| 815 | message="feat: add process", |
| 816 | commit_branch="dev", |
| 817 | committed_at=_now(), |
| 818 | author="gabriel", |
| 819 | ) |
| 820 | db_session.add(entry) |
| 821 | await _insert_vitals( |
| 822 | db_session, repo.repo_id, address, |
| 823 | change_count=99, version_count=5, |
| 824 | op_add=1, op_modify=98, |
| 825 | ) |
| 826 | await db_session.commit() |
| 827 | |
| 828 | r = await client.get(f"/gabriel/myrepo/symbol/{address}") |
| 829 | assert r.status_code == 200 |
| 830 | assert b"99" in r.content |
| 831 | |
| 832 | async def test_D302_page_renders_coupling_from_coupling_table( |
| 833 | self, db_session: AsyncSession, client: AsyncClient |
| 834 | ) -> None: |
| 835 | """D302: co-change section is populated from MusehubSymbolCoupling rows.""" |
| 836 | repo = await create_repo(db_session, owner="gabriel", slug="myrepo2") |
| 837 | await db_session.commit() |
| 838 | |
| 839 | address = "src/a.py::fn_a" |
| 840 | cid = _lid() |
| 841 | entry = MusehubSymbolHistoryEntry( |
| 842 | repo_id=repo.repo_id, |
| 843 | address=address, |
| 844 | commit_id=cid, |
| 845 | op="insert", |
| 846 | content_id=_cid(), |
| 847 | message="feat: add fn_a", |
| 848 | commit_branch="dev", |
| 849 | committed_at=_now(), |
| 850 | author="gabriel", |
| 851 | ) |
| 852 | db_session.add(entry) |
| 853 | await _insert_coupling(db_session, repo.repo_id, address, "src/b.py::fn_b", 7) |
| 854 | await db_session.commit() |
| 855 | |
| 856 | r = await client.get(f"/gabriel/myrepo2/symbol/{address}") |
| 857 | assert r.status_code == 200 |
| 858 | assert b"src/b.py::fn_b" in r.content |
| 859 | |
| 860 | async def test_D303_history_timeline_needs_no_commit_join( |
| 861 | self, db_session: AsyncSession, client: AsyncClient |
| 862 | ) -> None: |
| 863 | """D303: timeline message comes from history entry itself, not a join.""" |
| 864 | repo = await create_repo(db_session, owner="gabriel", slug="myrepo3") |
| 865 | await db_session.commit() |
| 866 | |
| 867 | address = "src/core.py::do_thing" |
| 868 | cid = _lid() |
| 869 | entry = MusehubSymbolHistoryEntry( |
| 870 | repo_id=repo.repo_id, |
| 871 | address=address, |
| 872 | commit_id=cid, |
| 873 | op="insert", |
| 874 | content_id=_cid(), |
| 875 | message="feat: do thing implemented", |
| 876 | commit_branch="dev", |
| 877 | committed_at=_now(), |
| 878 | author="gabriel", |
| 879 | ) |
| 880 | db_session.add(entry) |
| 881 | await db_session.commit() |
| 882 | |
| 883 | r = await client.get(f"/gabriel/myrepo3/symbol/{address}") |
| 884 | assert r.status_code == 200 |
| 885 | assert b"feat: do thing implemented" in r.content |
| 886 | |
| 887 | |
| 888 | # --------------------------------------------------------------------------- |
| 889 | # D4xx β Edge cases |
| 890 | # --------------------------------------------------------------------------- |
| 891 | |
| 892 | class TestEdgeCases: |
| 893 | """D401βD405: empty history, single entry, no coupling.""" |
| 894 | |
| 895 | async def test_D401_no_vitals_row_falls_back_gracefully( |
| 896 | self, db_session: AsyncSession, client: AsyncClient |
| 897 | ) -> None: |
| 898 | """D401: page renders even when MusehubSymbolVitals row is absent (old data).""" |
| 899 | repo = await create_repo(db_session, owner="gabriel", slug="oldrepo") |
| 900 | await db_session.commit() |
| 901 | |
| 902 | address = "src/old.py::legacy" |
| 903 | entry = MusehubSymbolHistoryEntry( |
| 904 | repo_id=repo.repo_id, |
| 905 | address=address, |
| 906 | commit_id=_lid(), |
| 907 | op="insert", |
| 908 | content_id=_cid(), |
| 909 | message=None, |
| 910 | commit_branch=None, |
| 911 | committed_at=_now(), |
| 912 | author="gabriel", |
| 913 | ) |
| 914 | db_session.add(entry) |
| 915 | await db_session.commit() |
| 916 | |
| 917 | r = await client.get(f"/gabriel/oldrepo/symbol/{address}") |
| 918 | assert r.status_code == 200 |
| 919 | |
| 920 | async def test_D402_no_coupling_rows_renders_empty_section( |
| 921 | self, db_session: AsyncSession, client: AsyncClient |
| 922 | ) -> None: |
| 923 | """D402: coupling section renders without error when table has no rows for symbol.""" |
| 924 | repo = await create_repo(db_session, owner="gabriel", slug="lonerepo") |
| 925 | await db_session.commit() |
| 926 | |
| 927 | address = "src/lone.py::solo" |
| 928 | entry = MusehubSymbolHistoryEntry( |
| 929 | repo_id=repo.repo_id, |
| 930 | address=address, |
| 931 | commit_id=_lid(), |
| 932 | op="insert", |
| 933 | content_id=_cid(), |
| 934 | message="add solo", |
| 935 | commit_branch="dev", |
| 936 | committed_at=_now(), |
| 937 | author="gabriel", |
| 938 | ) |
| 939 | db_session.add(entry) |
| 940 | await db_session.commit() |
| 941 | |
| 942 | r = await client.get(f"/gabriel/lonerepo/symbol/{address}") |
| 943 | assert r.status_code == 200 |
| 944 | |
| 945 | async def test_D403_coupling_pagination_second_page( |
| 946 | self, db_session: AsyncSession, client: AsyncClient |
| 947 | ) -> None: |
| 948 | """D403: coupling_cursor offset into MusehubSymbolCoupling works correctly.""" |
| 949 | repo = await create_repo(db_session, owner="gabriel", slug="bigcoupling") |
| 950 | await db_session.commit() |
| 951 | |
| 952 | address = "src/hub.py::central" |
| 953 | entry = MusehubSymbolHistoryEntry( |
| 954 | repo_id=repo.repo_id, |
| 955 | address=address, |
| 956 | commit_id=_lid(), |
| 957 | op="insert", |
| 958 | content_id=_cid(), |
| 959 | message="add central", |
| 960 | commit_branch="dev", |
| 961 | committed_at=_now(), |
| 962 | author="gabriel", |
| 963 | ) |
| 964 | db_session.add(entry) |
| 965 | for i in range(20): |
| 966 | await _insert_coupling( |
| 967 | db_session, repo.repo_id, address, |
| 968 | f"src/dep{i}.py::fn", shared_commits=20 - i, |
| 969 | ) |
| 970 | await db_session.commit() |
| 971 | |
| 972 | r = await client.get(f"/gabriel/bigcoupling/symbol/{address}?coupling_cursor=15") |
| 973 | assert r.status_code == 200 |
| 974 | assert b"src/dep" in r.content |
| 975 | |
| 976 | async def test_D404_vitals_op_breakdown_correct(self, db_session: AsyncSession) -> None: |
| 977 | """D404: op_add/modify/delete/move on vitals row sum to change_count.""" |
| 978 | repo = await create_repo(db_session, owner="gabriel") |
| 979 | row = await _insert_vitals( |
| 980 | db_session, repo.repo_id, "src/x.py::fn", |
| 981 | change_count=10, version_count=4, |
| 982 | op_add=1, op_modify=7, op_delete=1, op_move=1, |
| 983 | ) |
| 984 | await db_session.flush() |
| 985 | assert row.op_add + row.op_modify + row.op_delete + row.op_move == row.change_count |
| 986 | |
| 987 | async def test_D405_coupling_page_beyond_end_returns_200( |
| 988 | self, db_session: AsyncSession, client: AsyncClient |
| 989 | ) -> None: |
| 990 | """D405: requesting a coupling offset past the end renders empty section, not 500.""" |
| 991 | repo = await create_repo(db_session, owner="gabriel", slug="smallcoupling") |
| 992 | await db_session.commit() |
| 993 | |
| 994 | address = "src/tiny.py::fn" |
| 995 | entry = MusehubSymbolHistoryEntry( |
| 996 | repo_id=repo.repo_id, |
| 997 | address=address, |
| 998 | commit_id=_lid(), |
| 999 | op="insert", |
| 1000 | content_id=_cid(), |
| 1001 | message="add tiny fn", |
| 1002 | commit_branch="dev", |
| 1003 | committed_at=_now(), |
| 1004 | author="gabriel", |
| 1005 | ) |
| 1006 | db_session.add(entry) |
| 1007 | await _insert_coupling(db_session, repo.repo_id, address, "src/b.py::other", 3) |
| 1008 | await db_session.commit() |
| 1009 | |
| 1010 | r = await client.get(f"/gabriel/smallcoupling/symbol/{address}?coupling_cursor=9999") |
| 1011 | assert r.status_code == 200 |