gabriel / musehub public
test_diff_page_ssr.py python
390 lines 13.1 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 20 days ago
1 """SSR tests for the commit diff page.
2
3 The diff page must render all file diffs server-side — no "Loading diff…"
4 spinner in the initial HTML response. This file drives the implementation of
5 SSR diff rendering in ui_commits.py::diff_page.
6
7 Test matrix
8 -----------
9 test_diff_page_renders_without_loading_spinner
10 The response must NOT contain the loading-state text ("Loading diff…").
11
12 test_diff_page_renders_stats_bar
13 The response must contain the stats bar element (.df3-stats-bar).
14
15 test_diff_page_added_file_shows_plus_lines
16 An added file's diff lines have "+" signs rendered in the HTML.
17
18 test_diff_page_removed_file_shows_minus_lines
19 A removed file's diff lines have "−" signs rendered in the HTML.
20
21 test_diff_page_modified_file_shows_cohen_hunk_header
22 A modified file's hunk header includes a Cohen action label
23 (e.g. "[change: inserted]" or "[change: modified]").
24
25 test_diff_page_modified_file_shows_add_and_del_lines
26 A modified file has both "+" and "-" diff lines in the HTML.
27
28 test_diff_page_root_commit_no_parent_shows_added_file
29 A root commit (no parent) still renders the diff for the added file.
30
31 test_diff_page_no_changes_shows_empty_state
32 A commit with no snapshot diffs renders the empty-state element.
33
34 test_diff_page_returns_200_for_unknown_commit
35 An unknown commit_id returns 200 (page shell) without 500.
36 """
37 from __future__ import annotations
38
39 import secrets
40 from datetime import datetime, timezone
41 from unittest.mock import AsyncMock, MagicMock, patch
42
43 import pytest
44 from httpx import AsyncClient
45 from sqlalchemy.ext.asyncio import AsyncSession
46
47 from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_repo_id
48 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo
49 from musehub.types.json_types import StrDict
50
51 # ── Constants ─────────────────────────────────────────────────────────────────
52
53 _OWNER = "diffssrowner"
54 _SLUG = "diff-ssr-repo"
55
56 _SHA_PARENT = "pp" + "0" * 62
57 _SHA_COMMIT = "cc" + "0" * 62
58
59 _SNAP_PARENT = "sp" + "0" * 62
60 _SNAP_COMMIT = "sc" + "0" * 62
61
62 # object IDs stored in fake manifests
63 _OID_OLD = "sha256:" + "a" * 64
64 _OID_NEW = "sha256:" + "b" * 64
65
66 _OLD_CONTENT = b"line one\nline two\nline three\n"
67 _NEW_CONTENT = b"line one\nline two modified\nline three\nline four inserted\n"
68
69
70 # ── Seed helpers ──────────────────────────────────────────────────────────────
71
72
73 async def _seed_repo(db: AsyncSession) -> str:
74 owner_id = compute_identity_id(_OWNER.encode())
75 created_at = datetime.now(tz=timezone.utc)
76 repo = MusehubRepo(
77 repo_id=compute_repo_id(owner_id, _SLUG, "code", created_at.isoformat()),
78 name=_SLUG,
79 owner=_OWNER,
80 slug=_SLUG,
81 visibility="public",
82 owner_user_id=owner_id,
83 created_at=created_at,
84 updated_at=created_at,
85 )
86 db.add(repo)
87 await db.flush()
88 return str(repo.repo_id)
89
90
91 async def _seed_commit_pair(db: AsyncSession, repo_id: str) -> None:
92 """Seed a parent commit and a child commit, both with snapshot IDs."""
93 db.add(MusehubCommit(
94 commit_id=_SHA_PARENT,
95 branch="main",
96 parent_ids=[],
97 message="initial",
98 author="gabriel",
99 timestamp=datetime.now(tz=timezone.utc),
100 snapshot_id=_SNAP_PARENT,
101 ))
102 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=_SHA_PARENT))
103 db.add(MusehubCommit(
104 commit_id=_SHA_COMMIT,
105 branch="main",
106 parent_ids=[_SHA_PARENT],
107 message="feat: add feature",
108 author="gabriel",
109 timestamp=datetime.now(tz=timezone.utc),
110 snapshot_id=_SNAP_COMMIT,
111 ))
112 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=_SHA_COMMIT))
113 db.add(MusehubBranch(
114 branch_id=compute_branch_id(repo_id, "main"),
115 repo_id=repo_id,
116 name="main",
117 head_commit_id=_SHA_COMMIT,
118 ))
119 await db.flush()
120
121
122 async def _seed_root_commit(db: AsyncSession, repo_id: str) -> str:
123 """Seed a single root commit (no parent) with a snapshot."""
124 sha = "rr" + "0" * 62
125 db.add(MusehubCommit(
126 commit_id=sha,
127 branch="main",
128 parent_ids=[],
129 message="root commit",
130 author="gabriel",
131 timestamp=datetime.now(tz=timezone.utc),
132 snapshot_id=_SNAP_COMMIT,
133 ))
134 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=sha))
135 db.add(MusehubBranch(
136 branch_id=compute_branch_id(repo_id, "main"),
137 repo_id=repo_id,
138 name="main",
139 head_commit_id=sha,
140 ))
141 await db.flush()
142 return sha
143
144
145 # ── Manifest + storage mock helpers ───────────────────────────────────────────
146
147
148 def _make_manifests(
149 *,
150 added: bool = False,
151 removed: bool = False,
152 modified: bool = False,
153 ) -> tuple[StrDict, StrDict]:
154 """Return (old_manifest, new_manifest) for the requested scenario."""
155 old: dict[str, str] = {}
156 new: dict[str, str] = {}
157
158 if added:
159 new["src/added.py"] = _OID_NEW
160 if removed:
161 old["src/removed.py"] = _OID_OLD
162 if modified:
163 old["src/changed.py"] = _OID_OLD
164 new["src/changed.py"] = _OID_NEW
165
166 return old, new
167
168
169 def _storage_backend(*, old_bytes: bytes = _OLD_CONTENT, new_bytes: bytes = _NEW_CONTENT) -> None:
170 """Return a mock storage backend whose get_batch returns the two objects."""
171 backend = MagicMock()
172 backend.get_batch = AsyncMock(return_value={
173 _OID_OLD: old_bytes,
174 _OID_NEW: new_bytes,
175 })
176 return backend
177
178
179 # ── Patch context manager helper ──────────────────────────────────────────────
180
181
182 def _patch_manifests(old: StrDict, new: StrDict) -> None:
183 """Patch get_snapshot_manifest keyed on snap_id constants."""
184 async def _fake_manifest(_session: AsyncSession, snap_id: str) -> StrDict:
185 # _SNAP_COMMIT → new manifest; _SNAP_PARENT → old manifest
186 return new if snap_id == _SNAP_COMMIT else old
187
188 return patch(
189 "musehub.services.musehub_snapshot.get_snapshot_manifest",
190 side_effect=_fake_manifest,
191 )
192
193
194 # ── Tests ──────────────────────────────────────────────────────────────────────
195
196
197 @pytest.mark.asyncio
198 async def test_diff_page_renders_without_loading_spinner(
199 client: AsyncClient,
200 db_session: AsyncSession,
201 ) -> None:
202 """The initial HTML must not contain the loading spinner text."""
203 repo_id = await _seed_repo(db_session)
204 await _seed_commit_pair(db_session, repo_id)
205 await db_session.commit()
206
207 old_m, new_m = _make_manifests(modified=True)
208
209 with _patch_manifests(old_m, new_m), \
210 patch("musehub.storage.backends.get_backend", return_value=_storage_backend()):
211 resp = await client.get(f"/{_OWNER}/{_SLUG}/commits/{_SHA_COMMIT}/diff")
212
213 assert resp.status_code == 200
214 assert "Loading diff" not in resp.text
215
216
217 @pytest.mark.asyncio
218 async def test_diff_page_renders_stats_bar(
219 client: AsyncClient,
220 db_session: AsyncSession,
221 ) -> None:
222 """The stats bar element must be present in the server-rendered HTML."""
223 repo_id = await _seed_repo(db_session)
224 await _seed_commit_pair(db_session, repo_id)
225 await db_session.commit()
226
227 old_m, new_m = _make_manifests(modified=True)
228
229 with _patch_manifests(old_m, new_m), \
230 patch("musehub.storage.backends.get_backend", return_value=_storage_backend()):
231 resp = await client.get(f"/{_OWNER}/{_SLUG}/commits/{_SHA_COMMIT}/diff")
232
233 assert resp.status_code == 200
234 assert "df3-stats-bar" in resp.text
235
236
237 @pytest.mark.asyncio
238 async def test_diff_page_added_file_shows_plus_lines(
239 client: AsyncClient,
240 db_session: AsyncSession,
241 ) -> None:
242 """An added file's content is rendered with '+' lines in the diff."""
243 repo_id = await _seed_repo(db_session)
244 await _seed_commit_pair(db_session, repo_id)
245 await db_session.commit()
246
247 old_m, new_m = _make_manifests(added=True)
248
249 with _patch_manifests(old_m, new_m), \
250 patch("musehub.storage.backends.get_backend", return_value=_storage_backend(
251 old_bytes=b"", new_bytes=_NEW_CONTENT
252 )):
253 resp = await client.get(f"/{_OWNER}/{_SLUG}/commits/{_SHA_COMMIT}/diff")
254
255 assert resp.status_code == 200
256 # File path present
257 assert "src/added.py" in resp.text
258 # At least one '+' diff line
259 assert "df3-dl-add" in resp.text
260
261
262 @pytest.mark.asyncio
263 async def test_diff_page_removed_file_shows_minus_lines(
264 client: AsyncClient,
265 db_session: AsyncSession,
266 ) -> None:
267 """A removed file is rendered with '−' lines in the diff."""
268 repo_id = await _seed_repo(db_session)
269 await _seed_commit_pair(db_session, repo_id)
270 await db_session.commit()
271
272 old_m, new_m = _make_manifests(removed=True)
273
274 with _patch_manifests(old_m, new_m), \
275 patch("musehub.storage.backends.get_backend", return_value=_storage_backend(
276 old_bytes=_OLD_CONTENT, new_bytes=b""
277 )):
278 resp = await client.get(f"/{_OWNER}/{_SLUG}/commits/{_SHA_COMMIT}/diff")
279
280 assert resp.status_code == 200
281 assert "src/removed.py" in resp.text
282 assert "df3-dl-del" in resp.text
283
284
285 @pytest.mark.asyncio
286 async def test_diff_page_modified_file_shows_cohen_hunk_header(
287 client: AsyncClient,
288 db_session: AsyncSession,
289 ) -> None:
290 """A modified file's hunk header carries a Cohen action label."""
291 repo_id = await _seed_repo(db_session)
292 await _seed_commit_pair(db_session, repo_id)
293 await db_session.commit()
294
295 old_m, new_m = _make_manifests(modified=True)
296
297 with _patch_manifests(old_m, new_m), \
298 patch("musehub.storage.backends.get_backend", return_value=_storage_backend()):
299 resp = await client.get(f"/{_OWNER}/{_SLUG}/commits/{_SHA_COMMIT}/diff")
300
301 assert resp.status_code == 200
302 # Cohen label: [change: inserted], [change: modified], or [change: deleted]
303 assert "[change:" in resp.text
304
305
306 @pytest.mark.asyncio
307 async def test_diff_page_modified_file_shows_add_and_del_lines(
308 client: AsyncClient,
309 db_session: AsyncSession,
310 ) -> None:
311 """A modified file produces both addition and deletion diff rows."""
312 repo_id = await _seed_repo(db_session)
313 await _seed_commit_pair(db_session, repo_id)
314 await db_session.commit()
315
316 old_m, new_m = _make_manifests(modified=True)
317
318 with _patch_manifests(old_m, new_m), \
319 patch("musehub.storage.backends.get_backend", return_value=_storage_backend()):
320 resp = await client.get(f"/{_OWNER}/{_SLUG}/commits/{_SHA_COMMIT}/diff")
321
322 assert resp.status_code == 200
323 assert "df3-dl-add" in resp.text
324 assert "df3-dl-del" in resp.text
325
326
327 @pytest.mark.asyncio
328 async def test_diff_page_root_commit_no_parent_shows_added_file(
329 client: AsyncClient,
330 db_session: AsyncSession,
331 ) -> None:
332 """Root commit (no parent snapshot) renders all files as added."""
333 repo_id = await _seed_repo(db_session)
334 sha = await _seed_root_commit(db_session, repo_id)
335 await db_session.commit()
336
337 new_m = {"src/new_file.py": _OID_NEW}
338
339 async def _fake_manifest(_session: AsyncSession, snap_id: str) -> StrDict:
340 return new_m
341
342 with patch("musehub.services.musehub_snapshot.get_snapshot_manifest",
343 side_effect=_fake_manifest), \
344 patch("musehub.storage.backends.get_backend",
345 return_value=_storage_backend(old_bytes=b"", new_bytes=_NEW_CONTENT)):
346 resp = await client.get(f"/{_OWNER}/{_SLUG}/commits/{sha}/diff")
347
348 assert resp.status_code == 200
349 assert "src/new_file.py" in resp.text
350 assert "df3-dl-add" in resp.text
351
352
353 @pytest.mark.asyncio
354 async def test_diff_page_no_changes_shows_empty_state(
355 client: AsyncClient,
356 db_session: AsyncSession,
357 ) -> None:
358 """A commit with identical snapshots renders the empty-state element."""
359 repo_id = await _seed_repo(db_session)
360 await _seed_commit_pair(db_session, repo_id)
361 await db_session.commit()
362
363 # Both manifests identical → no added/modified/removed
364 same_m = {"src/stable.py": _OID_OLD}
365
366 async def _fake_manifest(_session: AsyncSession, snap_id: str) -> StrDict:
367 return same_m
368
369 with patch("musehub.services.musehub_snapshot.get_snapshot_manifest",
370 side_effect=_fake_manifest):
371 resp = await client.get(f"/{_OWNER}/{_SLUG}/commits/{_SHA_COMMIT}/diff")
372
373 assert resp.status_code == 200
374 assert "df3-empty" in resp.text
375
376
377 @pytest.mark.asyncio
378 async def test_diff_page_returns_200_for_unknown_commit(
379 client: AsyncClient,
380 db_session: AsyncSession,
381 ) -> None:
382 """An unknown commit ID returns a 200 page shell — never a 500."""
383 repo_id = await _seed_repo(db_session)
384 # No commit seeded — repo exists but commit does not
385 await db_session.commit()
386
387 unknown_sha = "ee" + "0" * 62
388 resp = await client.get(f"/{_OWNER}/{_SLUG}/commits/{unknown_sha}/diff")
389
390 assert resp.status_code == 200
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 20 days ago