gabriel / musehub public
test_negotiate_disk_read.py python
386 lines 15.1 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Phase 4: wire_negotiate reads disk, not DB — TDD (RED → GREEN).
2
3 Seven tiers:
4 Tier 1 — _commit_exists_on_disk returns True when object file present
5 Tier 2 — _commit_exists_on_disk returns False when object file absent
6 Tier 3 — wire_negotiate acks a have-ID that exists on disk (even if not in DB)
7 Tier 4 — wire_negotiate does NOT ack a have-ID absent from disk (even if in DB)
8 Tier 5 — wire_negotiate acks nothing when have list is empty
9 Tier 6 — wire_negotiate ready=True for full clone (no have, want only)
10 Tier 7 — wire_negotiate uses no DB query for have-set resolution
11 """
12 from __future__ import annotations
13
14 import secrets
15 from pathlib import Path
16 from unittest.mock import AsyncMock, MagicMock, patch, call
17
18 import pytest
19 import pytest_asyncio
20 from sqlalchemy.ext.asyncio import AsyncSession
21
22 from muse.core.types import blob_id, long_id
23
24
25 # ── helpers ───────────────────────────────────────────────────────────────────
26
27 def _oid() -> str:
28 return long_id(secrets.token_hex(32))
29
30
31 def _write_object(repo_root: Path, object_id: str, content: bytes = b"commit data") -> None:
32 """Write a fake object file at the canonical path for object_id."""
33 from muse.core.object_store import object_path
34 from muse.core.paths import server_objects_dir
35 p = object_path(repo_root, object_id, objects_base=server_objects_dir(repo_root))
36 p.parent.mkdir(parents=True, exist_ok=True)
37 p.write_bytes(content)
38
39
40 def _repo_root(tmp_path: Path, owner: str = "gabriel", slug: str = "test-repo") -> Path:
41 root = tmp_path / owner / slug
42 (root / "refs" / "heads").mkdir(parents=True, exist_ok=True)
43 (root / "objects").mkdir(parents=True, exist_ok=True)
44 return root
45
46
47 # ── Tier 1: _commit_exists_on_disk returns True ───────────────────────────────
48
49 class TestCommitExistsOnDiskTrue:
50 def test_returns_true_when_object_file_exists(self, tmp_path: Path) -> None:
51 from musehub.services.musehub_wire import _commit_exists_on_disk
52
53 repo_root = _repo_root(tmp_path)
54 commit_id = _oid()
55 _write_object(repo_root, commit_id)
56
57 result = _commit_exists_on_disk(repo_root, commit_id)
58 assert result is True
59
60 def test_returns_true_for_multiple_commits(self, tmp_path: Path) -> None:
61 from musehub.services.musehub_wire import _commit_exists_on_disk
62
63 repo_root = _repo_root(tmp_path)
64 ids = [_oid() for _ in range(5)]
65 for oid in ids:
66 _write_object(repo_root, oid)
67
68 for oid in ids:
69 assert _commit_exists_on_disk(repo_root, oid) is True
70
71 def test_returns_true_after_explicit_write(self, tmp_path: Path) -> None:
72 from musehub.services.musehub_wire import _commit_exists_on_disk
73 from muse.core.object_store import object_path
74 from muse.core.paths import server_objects_dir
75
76 repo_root = _repo_root(tmp_path)
77 commit_id = _oid()
78
79 # Not present yet
80 assert _commit_exists_on_disk(repo_root, commit_id) is False
81
82 # Write it
83 _write_object(repo_root, commit_id)
84
85 # Now present
86 assert _commit_exists_on_disk(repo_root, commit_id) is True
87
88
89 # ── Tier 2: _commit_exists_on_disk returns False ─────────────────────────────
90
91 class TestCommitExistsOnDiskFalse:
92 def test_returns_false_for_unknown_commit(self, tmp_path: Path) -> None:
93 from musehub.services.musehub_wire import _commit_exists_on_disk
94
95 repo_root = _repo_root(tmp_path)
96 assert _commit_exists_on_disk(repo_root, _oid()) is False
97
98 def test_returns_false_for_empty_repo(self, tmp_path: Path) -> None:
99 from musehub.services.musehub_wire import _commit_exists_on_disk
100
101 repo_root = _repo_root(tmp_path)
102 for _ in range(3):
103 assert _commit_exists_on_disk(repo_root, _oid()) is False
104
105 def test_returns_false_after_file_deleted(self, tmp_path: Path) -> None:
106 from musehub.services.musehub_wire import _commit_exists_on_disk
107 from muse.core.object_store import object_path
108 from muse.core.paths import server_objects_dir
109
110 repo_root = _repo_root(tmp_path)
111 commit_id = _oid()
112 _write_object(repo_root, commit_id)
113
114 p = object_path(repo_root, commit_id, objects_base=server_objects_dir(repo_root))
115 p.unlink()
116
117 assert _commit_exists_on_disk(repo_root, commit_id) is False
118
119
120 # ── Tier 3: negotiate acks disk-present commits ───────────────────────────────
121
122 class TestNegotiateAcksDiskPresent:
123 @pytest.mark.asyncio
124 async def test_acks_commit_present_on_disk_not_in_db(
125 self, tmp_path: Path, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
126 ) -> None:
127 """Negotiate acks a have-ID that exists on disk even if DB has no record."""
128 from musehub.services.musehub_wire import wire_negotiate
129 from musehub.models.wire import WireNegotiateRequest
130 from musehub.config import settings
131
132 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
133 repo = await _make_repo(db_session, "Phase4 Disk Ack")
134
135 commit_id = _oid()
136 repo_root = tmp_path / repo.owner / repo.slug
137 _write_object(repo_root, commit_id)
138
139 req = WireNegotiateRequest(have=[commit_id], want=[])
140 resp = await wire_negotiate(db_session, repo.repo_id, req)
141
142 assert commit_id in resp.ack
143
144 @pytest.mark.asyncio
145 async def test_acks_multiple_disk_commits(
146 self, tmp_path: Path, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
147 ) -> None:
148 from musehub.services.musehub_wire import wire_negotiate
149 from musehub.models.wire import WireNegotiateRequest
150 from musehub.config import settings
151
152 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
153 repo = await _make_repo(db_session, "Phase4 Multi Disk Ack")
154
155 ids = [_oid() for _ in range(4)]
156 repo_root = tmp_path / repo.owner / repo.slug
157 for oid in ids:
158 _write_object(repo_root, oid)
159
160 req = WireNegotiateRequest(have=ids, want=[])
161 resp = await wire_negotiate(db_session, repo.repo_id, req)
162
163 for oid in ids:
164 assert oid in resp.ack
165
166
167 # ── Tier 4: negotiate does NOT ack disk-absent commits ───────────────────────
168
169 class TestNegotiateRejectsDiskAbsent:
170 @pytest.mark.asyncio
171 async def test_does_not_ack_commit_in_db_but_not_on_disk(
172 self, tmp_path: Path, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
173 ) -> None:
174 """DB-only commit must NOT be acked — disk is the source of truth."""
175 from musehub.services.musehub_wire import wire_negotiate
176 from musehub.models.wire import WireNegotiateRequest
177 from musehub.db.musehub_models import MusehubCommit
178 from musehub.config import settings
179
180 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
181 repo = await _make_repo(db_session, "Phase4 DB Only")
182
183 commit_id = _oid()
184
185 # Write to DB only — not to disk
186 from datetime import datetime, timezone
187 db_commit = MusehubCommit(
188 commit_id=commit_id,
189 repo_id=repo.repo_id,
190 branch="main",
191 message="db-only commit",
192 author="gabriel",
193 parent_ids=[],
194 snapshot_id=_oid(),
195 timestamp=datetime.now(tz=timezone.utc),
196 )
197 db_session.add(db_commit)
198 await db_session.commit()
199
200 req = WireNegotiateRequest(have=[commit_id], want=[])
201 resp = await wire_negotiate(db_session, repo.repo_id, req)
202
203 assert commit_id not in resp.ack
204
205 @pytest.mark.asyncio
206 async def test_does_not_ack_unknown_commit(
207 self, tmp_path: Path, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
208 ) -> None:
209 from musehub.services.musehub_wire import wire_negotiate
210 from musehub.models.wire import WireNegotiateRequest
211 from musehub.config import settings
212
213 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
214 repo = await _make_repo(db_session, "Phase4 Unknown Commit")
215
216 unknown_id = _oid()
217 req = WireNegotiateRequest(have=[unknown_id], want=[])
218 resp = await wire_negotiate(db_session, repo.repo_id, req)
219
220 assert unknown_id not in resp.ack
221
222
223 # ── Tier 5: empty have list ───────────────────────────────────────────────────
224
225 class TestNegotiateEmptyHave:
226 @pytest.mark.asyncio
227 async def test_empty_have_yields_empty_ack(
228 self, tmp_path: Path, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
229 ) -> None:
230 from musehub.services.musehub_wire import wire_negotiate
231 from musehub.models.wire import WireNegotiateRequest
232 from musehub.config import settings
233
234 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
235 repo = await _make_repo(db_session, "Phase4 Empty Have")
236
237 req = WireNegotiateRequest(have=[], want=[])
238 resp = await wire_negotiate(db_session, repo.repo_id, req)
239
240 assert resp.ack == []
241
242 @pytest.mark.asyncio
243 async def test_partial_have_only_acks_disk_present(
244 self, tmp_path: Path, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
245 ) -> None:
246 from musehub.services.musehub_wire import wire_negotiate
247 from musehub.models.wire import WireNegotiateRequest
248 from musehub.config import settings
249
250 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
251 repo = await _make_repo(db_session, "Phase4 Partial Have")
252
253 present_id = _oid()
254 absent_id = _oid()
255
256 repo_root = tmp_path / repo.owner / repo.slug
257 _write_object(repo_root, present_id)
258
259 req = WireNegotiateRequest(have=[present_id, absent_id], want=[])
260 resp = await wire_negotiate(db_session, repo.repo_id, req)
261
262 assert present_id in resp.ack
263 assert absent_id not in resp.ack
264
265
266 # ── Tier 6: ready flag for full clone ────────────────────────────────────────
267
268 class TestNegotiateReadyFlag:
269 @pytest.mark.asyncio
270 async def test_ready_true_when_no_have_ids(
271 self, tmp_path: Path, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
272 ) -> None:
273 """Full clone: no have-IDs → ready=True (server sends everything)."""
274 from musehub.services.musehub_wire import wire_negotiate
275 from musehub.models.wire import WireNegotiateRequest
276 from musehub.config import settings
277
278 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
279 repo = await _make_repo(db_session, "Phase4 Full Clone")
280
281 req = WireNegotiateRequest(have=[], want=[_oid()])
282 resp = await wire_negotiate(db_session, repo.repo_id, req)
283
284 assert resp.ready is True
285
286 @pytest.mark.asyncio
287 async def test_ack_set_drives_ready_when_have_present(
288 self, tmp_path: Path, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
289 ) -> None:
290 """When have-IDs are acked and want is specified, ready depends on common_base."""
291 from musehub.services.musehub_wire import wire_negotiate
292 from musehub.models.wire import WireNegotiateRequest
293 from musehub.config import settings
294
295 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
296 repo = await _make_repo(db_session, "Phase4 Ready With Have")
297
298 # Write a have-commit to disk but nothing else
299 have_id = _oid()
300 repo_root = tmp_path / repo.owner / repo.slug
301 _write_object(repo_root, have_id)
302
303 req = WireNegotiateRequest(have=[have_id], want=[_oid()])
304 resp = await wire_negotiate(db_session, repo.repo_id, req)
305
306 # have_id acked, but common_base logic still applies
307 assert have_id in resp.ack
308
309
310 # ── Tier 7: wire_negotiate never queries musehub_commits for have resolution ──
311
312 class TestNegotiateNoDB:
313 @pytest.mark.asyncio
314 async def test_no_musehub_commits_query_for_have_ack(
315 self, tmp_path: Path, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
316 ) -> None:
317 """The have-set acknowledgment must not query musehub_commits."""
318 from musehub.services.musehub_wire import wire_negotiate
319 from musehub.models.wire import WireNegotiateRequest
320 from musehub.config import settings
321 import musehub.db.musehub_models as models
322
323 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
324 repo = await _make_repo(db_session, "Phase4 No DB Have")
325
326 commit_id = _oid()
327 repo_root = tmp_path / repo.owner / repo.slug
328 _write_object(repo_root, commit_id)
329
330 # Track SQL queries issued during negotiate
331 queries: list[str] = []
332 original_execute = db_session.execute
333
334 async def spy_execute(stmt, *args, **kwargs):
335 q = str(stmt.compile(compile_kwargs={"literal_binds": True})) if hasattr(stmt, 'compile') else str(stmt)
336 queries.append(q)
337 return await original_execute(stmt, *args, **kwargs)
338
339 monkeypatch.setattr(db_session, "execute", spy_execute)
340
341 req = WireNegotiateRequest(have=[commit_id], want=[])
342 await wire_negotiate(db_session, repo.repo_id, req)
343
344 # None of the queries should touch musehub_commits for have resolution
345 have_resolution_queries = [
346 q for q in queries
347 if "musehub_commit" in q.lower() and "have" in q.lower()
348 ]
349 assert not have_resolution_queries, (
350 f"wire_negotiate queried musehub_commits for have resolution: {have_resolution_queries}"
351 )
352
353
354 # ── shared fixture helpers ────────────────────────────────────────────────────
355
356 async def _make_repo(db_session: AsyncSession, name: str, owner: str = "gabriel") -> "MusehubRepo":
357 from datetime import datetime, timezone
358 from musehub.db.musehub_models import MusehubRepo, MusehubBranch
359 from musehub.core.genesis import compute_identity_id, compute_repo_id, compute_branch_id
360
361 owner_user_id = compute_identity_id(owner.encode())
362 slug = name.lower().replace(" ", "-")
363 created_at = datetime.now(tz=timezone.utc)
364 repo_id = compute_repo_id(owner_user_id, slug, "code", created_at.isoformat())
365 repo = MusehubRepo(
366 repo_id=repo_id,
367 name=name,
368 owner=owner,
369 slug=slug,
370 visibility="public",
371 owner_user_id=owner_user_id,
372 description="",
373 tags=[],
374 created_at=created_at,
375 )
376 db_session.add(repo)
377 await db_session.commit()
378 branch = MusehubBranch(
379 branch_id=compute_branch_id(repo_id, "main"),
380 repo_id=repo_id,
381 name="main",
382 )
383 db_session.add(branch)
384 await db_session.commit()
385 await db_session.refresh(repo)
386 return repo
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago