gabriel / musehub public
test_coordination.py python
1,093 lines 44.0 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 7 — Coordination (muse coord): 7-layer test suite.
2
3 Covers:
4 - musehub/api/routes/coord.py (push_coord, pull_coord, watch_coord HTTP handlers,
5 _resolve_repo, _assert_readable, _assert_writable)
6 - musehub/services/musehub_coord.py (coord_push, coord_pull, coord_watch_stream,
7 _row_to_out, write-once semantics, heartbeat upsert)
8 - musehub/services/musehub_coord_server.py (materialize_coord_record, list_reservations,
9 conflict_check, extend_reservation,
10 list_tasks, claim_task, complete_task, fail_task)
11 - musehub/models/coord.py (CoordRecordIn validators, CoordPushRequest,
12 CoordPollRequest, _validate_uuid4)
13 - musehub/db/coord_models.py (MusehubCoordRecord, MusehubCoordReservation,
14 MusehubCoordTask)
15
16 Layers:
17 1. Unit — model validators, pure helpers, no DB
18 2. Integration — real DB (PostgreSQL), service-layer calls, no HTTP
19 3. End-to-End — full HTTP via AsyncClient, real DB
20 4. Stress — 500-record push, 100 tasks, cursor pagination
21 5. Data Integrity — write-once, heartbeat upsert, constraint enforcement,
22 task lifecycle state machine
23 6. Security — auth guards, ownership enforcement, private-repo 404,
24 invalid kind rejection
25 7. Performance — latency budgets for push/pull/materialize
26 """
27 from __future__ import annotations
28
29 import asyncio
30 import time
31 import uuid
32 from datetime import datetime, timedelta, timezone
33
34 import pytest
35 import pytest_asyncio
36 from httpx import AsyncClient
37 from sqlalchemy.ext.asyncio import AsyncSession
38
39 from musehub.muse_contracts.json_types import JSONObject, StrDict
40 from musehub.models.coord import (
41 CoordPollRequest,
42 CoordPushRequest,
43 CoordRecordIn,
44 _VALID_KINDS,
45 )
46 from tests.factories import create_repo
47
48 # ---------------------------------------------------------------------------
49 # Local helpers
50 # ---------------------------------------------------------------------------
51
52 def _now() -> datetime:
53 return datetime.now(tz=timezone.utc)
54
55
56 def _uuid() -> str:
57 return str(uuid.uuid4())
58
59
60 def _record(
61 kind: str = "intent",
62 record_uuid: str | None = None,
63 run_id: str = "agent-1",
64 payload: JSONObject | None = None,
65 expires_at: datetime | None = None,
66 ) -> CoordRecordIn:
67 return CoordRecordIn(
68 kind=kind,
69 record_uuid=record_uuid or _uuid(),
70 run_id=run_id,
71 payload=payload or {"action": kind, "data": "test"},
72 expires_at=expires_at,
73 )
74
75
76 def _push_body(*records: CoordRecordIn) -> JSONObject:
77 return {"records": [r.model_dump(mode="json") for r in records]}
78
79
80 def _pull_body(since_id: int = 0, kinds: list[str] | None = None, limit: int = 500) -> JSONObject:
81 return {"since_id": since_id, "kinds": kinds or [], "limit": limit}
82
83
84 # ===========================================================================
85 # Layer 1 — Unit tests (model validators, pure helpers)
86 # ===========================================================================
87
88 class TestCoordRecordInValidators:
89 def test_valid_kinds_accepted(self) -> None:
90 for kind in _VALID_KINDS:
91 r = _record(kind=kind)
92 assert r.kind == kind
93
94 def test_invalid_kind_raises(self) -> None:
95 import pytest
96 with pytest.raises(Exception):
97 _record(kind="garbage")
98
99 def test_record_uuid_must_be_uuid4(self) -> None:
100 with pytest.raises(Exception):
101 _record(record_uuid="not-a-uuid")
102
103 def test_record_uuid_normalized_lowercase(self) -> None:
104 uid = str(uuid.uuid4()).upper()
105 r = _record(record_uuid=uid)
106 assert r.record_uuid == uid.lower()
107
108 def test_run_id_empty_string_allowed(self) -> None:
109 r = _record(run_id="")
110 assert r.run_id == ""
111
112 def test_expires_at_optional(self) -> None:
113 r = _record()
114 assert r.expires_at is None
115
116 def test_expires_at_accepted(self) -> None:
117 exp = _now() + timedelta(seconds=300)
118 r = _record(expires_at=exp)
119 assert r.expires_at is not None
120
121
122 class TestCoordPushRequestValidators:
123 def test_empty_records_rejected(self) -> None:
124 with pytest.raises(Exception):
125 CoordPushRequest(records=[])
126
127 def test_max_500_records_accepted(self) -> None:
128 records = [_record() for _ in range(500)]
129 req = CoordPushRequest(records=records)
130 assert len(req.records) == 500
131
132 def test_501_records_rejected(self) -> None:
133 with pytest.raises(Exception):
134 CoordPushRequest(records=[_record() for _ in range(501)])
135
136 def test_single_record_accepted(self) -> None:
137 req = CoordPushRequest(records=[_record()])
138 assert len(req.records) == 1
139
140
141 class TestCoordPollRequestValidators:
142 def test_default_values(self) -> None:
143 req = CoordPollRequest()
144 assert req.since_id == 0
145 assert req.kinds == []
146 assert req.limit == 500
147
148 def test_since_id_must_be_non_negative(self) -> None:
149 with pytest.raises(Exception):
150 CoordPollRequest(since_id=-1)
151
152 def test_invalid_kind_in_pull_rejected(self) -> None:
153 with pytest.raises(Exception):
154 CoordPollRequest(kinds=["garbage"])
155
156 def test_valid_kinds_filter_accepted(self) -> None:
157 req = CoordPollRequest(kinds=["reservation", "task"])
158 assert set(req.kinds) == {"reservation", "task"}
159
160 def test_limit_range(self) -> None:
161 assert CoordPollRequest(limit=1).limit == 1
162 assert CoordPollRequest(limit=1000).limit == 1000
163 with pytest.raises(Exception):
164 CoordPollRequest(limit=0)
165 with pytest.raises(Exception):
166 CoordPollRequest(limit=1001)
167
168
169 class TestValidKinds:
170 def test_all_expected_kinds_present(self) -> None:
171 expected = {"reservation", "intent", "release", "heartbeat",
172 "dependency", "task", "claim"}
173 assert expected == _VALID_KINDS
174
175
176 # ===========================================================================
177 # Layer 2 — Integration tests (real DB, service layer, no HTTP)
178 # ===========================================================================
179
180 class TestCoordPushIntegration:
181 @pytest.mark.asyncio
182 async def test_push_inserts_records(self, db_session: AsyncSession) -> None:
183 from musehub.services.musehub_coord import coord_push
184
185 repo = await create_repo(db_session, slug="push-insert")
186 req = CoordPushRequest(records=[_record("intent"), _record("dependency")])
187 resp = await coord_push(db_session, repo.repo_id, req)
188 assert resp.inserted == 2
189 assert resp.skipped == 0
190
191 @pytest.mark.asyncio
192 async def test_push_write_once_skips_duplicate(self, db_session: AsyncSession) -> None:
193 from musehub.services.musehub_coord import coord_push
194
195 repo = await create_repo(db_session, slug="push-writeonce")
196 rec = _record("intent")
197 req = CoordPushRequest(records=[rec])
198
199 resp1 = await coord_push(db_session, repo.repo_id, req)
200 assert resp1.inserted == 1
201
202 resp2 = await coord_push(db_session, repo.repo_id, req)
203 assert resp2.skipped == 1
204 assert resp2.inserted == 0
205
206 @pytest.mark.asyncio
207 async def test_push_heartbeat_upserts_payload(self, db_session: AsyncSession) -> None:
208 from musehub.services.musehub_coord import coord_push, coord_pull
209
210 repo = await create_repo(db_session, slug="push-hb-upsert")
211 uid = _uuid()
212 req1 = CoordPushRequest(records=[_record("heartbeat", record_uuid=uid,
213 payload={"tick": 1})])
214 req2 = CoordPushRequest(records=[_record("heartbeat", record_uuid=uid,
215 payload={"tick": 2})])
216
217 r1 = await coord_push(db_session, repo.repo_id, req1)
218 assert r1.inserted == 1
219
220 r2 = await coord_push(db_session, repo.repo_id, req2)
221 # Re-push of same heartbeat → upsert, counted as skipped (no new row)
222 assert r2.skipped == 1
223
224 # Payload should be updated
225 pull_resp = await coord_pull(db_session, repo.repo_id,
226 CoordPollRequest(kinds=["heartbeat"]))
227 assert len(pull_resp.records) == 1
228 assert pull_resp.records[0].payload["tick"] == 2
229
230 @pytest.mark.asyncio
231 async def test_push_all_valid_kinds(self, db_session: AsyncSession) -> None:
232 from musehub.services.musehub_coord import coord_push
233
234 repo = await create_repo(db_session, slug="push-all-kinds")
235 records = [_record(k) for k in _VALID_KINDS]
236 req = CoordPushRequest(records=records)
237 resp = await coord_push(db_session, repo.repo_id, req)
238 assert resp.inserted == len(_VALID_KINDS)
239
240 @pytest.mark.asyncio
241 async def test_push_materializes_reservation(self, db_session: AsyncSession) -> None:
242 from musehub.services.musehub_coord import coord_push
243 from musehub.services.musehub_coord_server import list_reservations
244
245 repo = await create_repo(db_session, slug="push-materialize-res")
246 uid = _uuid()
247 payload = {
248 "reservation_id": uid,
249 "run_id": "agent-42",
250 "addresses": ["src/main.py::process"],
251 "ttl_s": 300,
252 }
253 req = CoordPushRequest(records=[_record("reservation", record_uuid=uid, payload=payload)])
254 await coord_push(db_session, repo.repo_id, req)
255
256 reservations = await list_reservations(db_session, repo.repo_id)
257 assert len(reservations) == 1
258 assert reservations[0].symbol_address == "src/main.py::process"
259 assert reservations[0].agent_id == "agent-42"
260
261 @pytest.mark.asyncio
262 async def test_push_materializes_task(self, db_session: AsyncSession) -> None:
263 from musehub.services.musehub_coord import coord_push
264 from musehub.services.musehub_coord_server import list_tasks
265
266 repo = await create_repo(db_session, slug="push-materialize-task")
267 task_id = _uuid()
268 payload = {
269 "task_id": task_id,
270 "queue": "ci",
271 "priority": 10,
272 "created_by": "dispatcher",
273 }
274 req = CoordPushRequest(records=[_record("task", record_uuid=task_id, payload=payload)])
275 await coord_push(db_session, repo.repo_id, req)
276
277 tasks = await list_tasks(db_session, repo.repo_id)
278 assert len(tasks) == 1
279 assert tasks[0].task_id == task_id
280 assert tasks[0].queue == "ci"
281 assert tasks[0].priority == 10
282 assert tasks[0].status == "pending"
283
284
285 class TestCoordPullIntegration:
286 @pytest.mark.asyncio
287 async def test_pull_empty_returns_cursor_zero(self, db_session: AsyncSession) -> None:
288 from musehub.services.musehub_coord import coord_pull
289
290 repo = await create_repo(db_session, slug="pull-empty")
291 resp = await coord_pull(db_session, repo.repo_id, CoordPollRequest())
292 assert resp.records == []
293 assert resp.cursor == 0
294
295 @pytest.mark.asyncio
296 async def test_pull_returns_all_pushed_records(self, db_session: AsyncSession) -> None:
297 from musehub.services.musehub_coord import coord_push, coord_pull
298
299 repo = await create_repo(db_session, slug="pull-all")
300 req = CoordPushRequest(records=[_record("intent"), _record("dependency"),
301 _record("heartbeat")])
302 await coord_push(db_session, repo.repo_id, req)
303
304 resp = await coord_pull(db_session, repo.repo_id, CoordPollRequest())
305 assert len(resp.records) == 3
306 assert resp.cursor == resp.records[-1].id
307
308 @pytest.mark.asyncio
309 async def test_pull_since_id_cursor_pagination(self, db_session: AsyncSession) -> None:
310 from musehub.services.musehub_coord import coord_push, coord_pull
311
312 repo = await create_repo(db_session, slug="pull-cursor")
313 for _ in range(5):
314 await coord_push(db_session, repo.repo_id,
315 CoordPushRequest(records=[_record("intent")]))
316
317 # Fetch first 3
318 resp1 = await coord_pull(db_session, repo.repo_id,
319 CoordPollRequest(limit=3))
320 assert len(resp1.records) == 3
321 cursor = resp1.cursor
322
323 # Fetch next 2 using cursor
324 resp2 = await coord_pull(db_session, repo.repo_id,
325 CoordPollRequest(since_id=cursor))
326 assert len(resp2.records) == 2
327 # IDs must be strictly greater than cursor
328 assert all(r.id > cursor for r in resp2.records)
329
330 @pytest.mark.asyncio
331 async def test_pull_kinds_filter(self, db_session: AsyncSession) -> None:
332 from musehub.services.musehub_coord import coord_push, coord_pull
333
334 repo = await create_repo(db_session, slug="pull-kinds-filter")
335 await coord_push(db_session, repo.repo_id,
336 CoordPushRequest(records=[_record("intent"), _record("heartbeat"),
337 _record("dependency")]))
338
339 resp = await coord_pull(db_session, repo.repo_id,
340 CoordPollRequest(kinds=["intent"]))
341 assert len(resp.records) == 1
342 assert resp.records[0].kind == "intent"
343
344 @pytest.mark.asyncio
345 async def test_pull_ordered_oldest_first(self, db_session: AsyncSession) -> None:
346 from musehub.services.musehub_coord import coord_push, coord_pull
347
348 repo = await create_repo(db_session, slug="pull-ordered")
349 for _ in range(3):
350 await coord_push(db_session, repo.repo_id,
351 CoordPushRequest(records=[_record("intent")]))
352
353 resp = await coord_pull(db_session, repo.repo_id, CoordPollRequest())
354 ids = [r.id for r in resp.records]
355 assert ids == sorted(ids)
356
357 @pytest.mark.asyncio
358 async def test_pull_limit_respected(self, db_session: AsyncSession) -> None:
359 from musehub.services.musehub_coord import coord_push, coord_pull
360
361 repo = await create_repo(db_session, slug="pull-limit")
362 for _ in range(10):
363 await coord_push(db_session, repo.repo_id,
364 CoordPushRequest(records=[_record("intent")]))
365
366 resp = await coord_pull(db_session, repo.repo_id,
367 CoordPollRequest(limit=4))
368 assert len(resp.records) == 4
369
370
371 class TestCoordServerIntegration:
372 @pytest.mark.asyncio
373 async def test_conflict_check_no_reservations(self, db_session: AsyncSession) -> None:
374 from musehub.services.musehub_coord_server import conflict_check
375
376 repo = await create_repo(db_session, slug="conflict-empty")
377 result = await conflict_check(db_session, repo.repo_id, ["a.py::Fn"])
378 assert result == []
379
380 @pytest.mark.asyncio
381 async def test_conflict_check_finds_active_reservation(
382 self, db_session: AsyncSession
383 ) -> None:
384 from musehub.services.musehub_coord import coord_push
385 from musehub.services.musehub_coord_server import conflict_check
386
387 repo = await create_repo(db_session, slug="conflict-found")
388 uid = _uuid()
389 exp = _now() + timedelta(seconds=300)
390 payload = {
391 "reservation_id": uid,
392 "run_id": "worker-1",
393 "addresses": ["a.py::MyFn"],
394 "ttl_s": 300,
395 "expires_at": exp.isoformat(),
396 }
397 await coord_push(db_session, repo.repo_id,
398 CoordPushRequest(records=[_record("reservation", record_uuid=uid,
399 payload=payload,
400 expires_at=exp)]))
401
402 conflicts = await conflict_check(db_session, repo.repo_id, ["a.py::MyFn"])
403 assert len(conflicts) == 1
404 assert conflicts[0]["symbol_address"] == "a.py::MyFn"
405
406 @pytest.mark.asyncio
407 async def test_conflict_check_ignores_expired_reservation(
408 self, db_session: AsyncSession
409 ) -> None:
410 from musehub.services.musehub_coord import coord_push
411 from musehub.services.musehub_coord_server import conflict_check
412 from musehub.db import coord_models as _cm
413
414 repo = await create_repo(db_session, slug="conflict-expired")
415 uid = _uuid()
416 # Insert reservation directly with past expires_at
417 past = _now() - timedelta(seconds=10)
418 row = _cm.MusehubCoordReservation(
419 reservation_id=uid,
420 repo_id=repo.repo_id,
421 symbol_address="a.py::OldFn",
422 agent_id="old-agent",
423 ttl_s=10,
424 created_at=_now() - timedelta(seconds=20),
425 expires_at=past,
426 )
427 db_session.add(row)
428 await db_session.commit()
429
430 conflicts = await conflict_check(db_session, repo.repo_id, ["a.py::OldFn"])
431 assert conflicts == []
432
433 @pytest.mark.asyncio
434 async def test_extend_reservation(self, db_session: AsyncSession) -> None:
435 from musehub.services.musehub_coord import coord_push
436 from musehub.services.musehub_coord_server import extend_reservation, list_reservations
437
438 repo = await create_repo(db_session, slug="extend-reservation")
439 uid = _uuid()
440 exp = _now() + timedelta(seconds=60)
441 payload = {
442 "reservation_id": uid,
443 "run_id": "agent-ext",
444 "addresses": ["b.py::Fn"],
445 "ttl_s": 60,
446 "expires_at": exp.isoformat(),
447 }
448 await coord_push(db_session, repo.repo_id,
449 CoordPushRequest(records=[_record("reservation", record_uuid=uid,
450 payload=payload, expires_at=exp)]))
451
452 res_before = await list_reservations(db_session, repo.repo_id)
453 old_exp = res_before[0].expires_at
454
455 updated = await extend_reservation(db_session, repo.repo_id, uid, extend_by_s=600)
456 assert updated is not None
457 # New expiry must be later than original
458 new_exp = updated.expires_at
459 if old_exp.tzinfo is None:
460 old_exp = old_exp.replace(tzinfo=timezone.utc)
461 if new_exp.tzinfo is None:
462 new_exp = new_exp.replace(tzinfo=timezone.utc)
463 assert new_exp > old_exp
464
465 @pytest.mark.asyncio
466 async def test_task_lifecycle_claim_complete(self, db_session: AsyncSession) -> None:
467 from musehub.services.musehub_coord import coord_push
468 from musehub.services.musehub_coord_server import claim_task, complete_task
469
470 repo = await create_repo(db_session, slug="task-lifecycle")
471 task_id = _uuid()
472 payload = {"task_id": task_id, "queue": "default", "priority": 50,
473 "created_by": "dispatcher"}
474 await coord_push(db_session, repo.repo_id,
475 CoordPushRequest(records=[_record("task", record_uuid=task_id,
476 payload=payload)]))
477
478 claimed = await claim_task(db_session, repo.repo_id, task_id, "worker-1")
479 assert claimed is not None
480 assert claimed.status == "claimed"
481 assert claimed.claimed_by == "worker-1"
482
483 completed = await complete_task(db_session, repo.repo_id, task_id, "worker-1",
484 result={"output": "done"})
485 assert completed is not None
486 assert completed.status == "completed"
487 assert completed.payload.get("result") == {"output": "done"}
488
489 @pytest.mark.asyncio
490 async def test_task_lifecycle_claim_fail(self, db_session: AsyncSession) -> None:
491 from musehub.services.musehub_coord import coord_push
492 from musehub.services.musehub_coord_server import claim_task, fail_task
493
494 repo = await create_repo(db_session, slug="task-fail")
495 task_id = _uuid()
496 payload = {"task_id": task_id, "queue": "default", "priority": 50}
497 await coord_push(db_session, repo.repo_id,
498 CoordPushRequest(records=[_record("task", record_uuid=task_id,
499 payload=payload)]))
500
501 await claim_task(db_session, repo.repo_id, task_id, "worker-2")
502 failed = await fail_task(db_session, repo.repo_id, task_id, "worker-2",
503 reason="OOM")
504 assert failed is not None
505 assert failed.status == "failed"
506 assert failed.payload.get("failure_reason") == "OOM"
507
508 @pytest.mark.asyncio
509 async def test_claim_already_claimed_task_returns_none(
510 self, db_session: AsyncSession
511 ) -> None:
512 from musehub.services.musehub_coord import coord_push
513 from musehub.services.musehub_coord_server import claim_task
514
515 repo = await create_repo(db_session, slug="double-claim")
516 task_id = _uuid()
517 payload = {"task_id": task_id, "queue": "default"}
518 await coord_push(db_session, repo.repo_id,
519 CoordPushRequest(records=[_record("task", record_uuid=task_id,
520 payload=payload)]))
521
522 r1 = await claim_task(db_session, repo.repo_id, task_id, "worker-A")
523 assert r1 is not None
524 r2 = await claim_task(db_session, repo.repo_id, task_id, "worker-B")
525 assert r2 is None # Already claimed by worker-A
526
527 @pytest.mark.asyncio
528 async def test_list_tasks_filter_by_status(self, db_session: AsyncSession) -> None:
529 from musehub.services.musehub_coord import coord_push
530 from musehub.services.musehub_coord_server import claim_task, list_tasks
531
532 repo = await create_repo(db_session, slug="list-tasks-status")
533 for i in range(3):
534 tid = _uuid()
535 await coord_push(db_session, repo.repo_id,
536 CoordPushRequest(records=[_record("task", record_uuid=tid,
537 payload={"task_id": tid})]))
538 if i == 0:
539 await claim_task(db_session, repo.repo_id, tid, "worker-X")
540
541 pending = await list_tasks(db_session, repo.repo_id, status="pending")
542 claimed = await list_tasks(db_session, repo.repo_id, status="claimed")
543 assert len(pending) == 2
544 assert len(claimed) == 1
545
546
547 # ===========================================================================
548 # Layer 3 — End-to-End tests (full HTTP via AsyncClient, real DB)
549 # ===========================================================================
550
551 class TestCoordEndToEnd:
552 @pytest.mark.asyncio
553 async def test_push_404_unknown_repo(
554 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
555 ) -> None:
556 resp = await client.post(
557 "/ghost-owner/ghost-repo/coord/push",
558 json=_push_body(_record()),
559 headers=auth_headers,
560 )
561 assert resp.status_code == 404
562
563 @pytest.mark.asyncio
564 async def test_push_requires_auth(
565 self, client: AsyncClient, db_session: AsyncSession
566 ) -> None:
567 repo = await create_repo(db_session, slug="push-noauth")
568 await db_session.commit()
569 resp = await client.post(
570 f"/{repo.owner}/{repo.slug}/coord/push",
571 json=_push_body(_record()),
572 )
573 assert resp.status_code == 401
574
575 @pytest.mark.asyncio
576 async def test_push_403_non_owner(
577 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
578 ) -> None:
579 # auth_headers gives identity_id = _TEST_IDENTITY_ID; create repo with different owner_user_id
580 repo = await create_repo(db_session, slug="push-nonowner", owner_user_id="other-user-id")
581 await db_session.commit()
582 resp = await client.post(
583 f"/{repo.owner}/{repo.slug}/coord/push",
584 json=_push_body(_record()),
585 headers=auth_headers,
586 )
587 assert resp.status_code == 403
588
589 @pytest.mark.asyncio
590 async def test_push_success_returns_counts(
591 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
592 ) -> None:
593 from tests.conftest import _TEST_IDENTITY_ID
594 repo = await create_repo(db_session, slug="push-e2e-ok",
595 owner_user_id=_TEST_IDENTITY_ID)
596 await db_session.commit()
597 resp = await client.post(
598 f"/{repo.owner}/{repo.slug}/coord/push",
599 json=_push_body(_record("intent"), _record("dependency")),
600 headers=auth_headers,
601 )
602 assert resp.status_code == 200
603 data = resp.json()
604 assert data["inserted"] == 2
605 assert data["skipped"] == 0
606
607 @pytest.mark.asyncio
608 async def test_pull_public_repo_no_auth(
609 self, client: AsyncClient, db_session: AsyncSession
610 ) -> None:
611 repo = await create_repo(db_session, slug="pull-e2e-pub", visibility="public")
612 await db_session.commit()
613 resp = await client.post(
614 f"/{repo.owner}/{repo.slug}/coord/pull",
615 json=_pull_body(),
616 )
617 assert resp.status_code == 200
618 data = resp.json()
619 assert "records" in data
620 assert "cursor" in data
621
622 @pytest.mark.asyncio
623 async def test_pull_private_repo_404_no_auth(
624 self, client: AsyncClient, db_session: AsyncSession
625 ) -> None:
626 repo = await create_repo(db_session, slug="pull-e2e-priv", visibility="private")
627 await db_session.commit()
628 resp = await client.post(
629 f"/{repo.owner}/{repo.slug}/coord/pull",
630 json=_pull_body(),
631 )
632 assert resp.status_code == 404
633
634 @pytest.mark.asyncio
635 async def test_pull_returns_pushed_records(
636 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
637 ) -> None:
638 from tests.conftest import _TEST_IDENTITY_ID
639 repo = await create_repo(db_session, slug="pull-e2e-round",
640 owner_user_id=_TEST_IDENTITY_ID, visibility="public")
641 await db_session.commit()
642
643 push_resp = await client.post(
644 f"/{repo.owner}/{repo.slug}/coord/push",
645 json=_push_body(_record("intent"), _record("heartbeat")),
646 headers=auth_headers,
647 )
648 assert push_resp.status_code == 200
649
650 pull_resp = await client.post(
651 f"/{repo.owner}/{repo.slug}/coord/pull",
652 json=_pull_body(),
653 )
654 assert pull_resp.status_code == 200
655 data = pull_resp.json()
656 assert len(data["records"]) == 2
657
658 @pytest.mark.asyncio
659 async def test_pull_kinds_filter_via_http(
660 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
661 ) -> None:
662 from tests.conftest import _TEST_IDENTITY_ID
663 repo = await create_repo(db_session, slug="pull-e2e-filter",
664 owner_user_id=_TEST_IDENTITY_ID, visibility="public")
665 await db_session.commit()
666
667 await client.post(
668 f"/{repo.owner}/{repo.slug}/coord/push",
669 json=_push_body(_record("intent"), _record("dependency"), _record("heartbeat")),
670 headers=auth_headers,
671 )
672
673 pull_resp = await client.post(
674 f"/{repo.owner}/{repo.slug}/coord/pull",
675 json=_pull_body(kinds=["heartbeat"]),
676 )
677 assert pull_resp.status_code == 200
678 records = pull_resp.json()["records"]
679 assert len(records) == 1
680 assert records[0]["kind"] == "heartbeat"
681
682 @pytest.mark.asyncio
683 async def test_watch_invalid_kind_400(
684 self, client: AsyncClient, db_session: AsyncSession
685 ) -> None:
686 repo = await create_repo(db_session, slug="watch-invalid-kind", visibility="public")
687 await db_session.commit()
688 resp = await client.get(
689 f"/{repo.owner}/{repo.slug}/coord/watch",
690 params={"kinds": "garbage"},
691 )
692 assert resp.status_code == 400
693
694 @pytest.mark.asyncio
695 async def test_watch_404_unknown_repo(
696 self, client: AsyncClient, db_session: AsyncSession
697 ) -> None:
698 resp = await client.get("/ghost/norepo/coord/watch")
699 assert resp.status_code == 404
700
701
702 # ===========================================================================
703 # Layer 4 — Stress tests
704 # ===========================================================================
705
706 class TestStress:
707 @pytest.mark.asyncio
708 async def test_push_500_records_single_call(self, db_session: AsyncSession) -> None:
709 from musehub.services.musehub_coord import coord_push, coord_pull
710
711 repo = await create_repo(db_session, slug="stress-push-500")
712 records = [_record("intent") for _ in range(500)]
713 req = CoordPushRequest(records=records)
714 resp = await coord_push(db_session, repo.repo_id, req)
715 assert resp.inserted == 500
716 assert resp.skipped == 0
717
718 # All 500 must be pullable
719 pull = await coord_pull(db_session, repo.repo_id,
720 CoordPollRequest(limit=1000))
721 assert len(pull.records) == 500
722
723 @pytest.mark.asyncio
724 async def test_cursor_pagination_through_500_records(
725 self, db_session: AsyncSession
726 ) -> None:
727 from musehub.services.musehub_coord import coord_push, coord_pull
728
729 repo = await create_repo(db_session, slug="stress-cursor-500")
730 records = [_record("dependency") for _ in range(500)]
731 await coord_push(db_session, repo.repo_id, CoordPushRequest(records=records))
732
733 cursor = 0
734 fetched = 0
735 pages = 0
736 while True:
737 page = await coord_pull(db_session, repo.repo_id,
738 CoordPollRequest(since_id=cursor, limit=100))
739 if not page.records:
740 break
741 fetched += len(page.records)
742 cursor = page.cursor
743 pages += 1
744 assert fetched == 500
745 assert pages == 5
746
747 @pytest.mark.asyncio
748 async def test_task_queue_100_tasks(self, db_session: AsyncSession) -> None:
749 from musehub.services.musehub_coord import coord_push
750 from musehub.services.musehub_coord_server import list_tasks, claim_task
751
752 repo = await create_repo(db_session, slug="stress-100-tasks")
753 for _ in range(100):
754 tid = _uuid()
755 payload = {"task_id": tid, "queue": "batch", "priority": 50}
756 await coord_push(db_session, repo.repo_id,
757 CoordPushRequest(records=[_record("task", record_uuid=tid,
758 payload=payload)]))
759
760 tasks = await list_tasks(db_session, repo.repo_id, queue="batch", limit=100)
761 assert len(tasks) == 100
762
763 # Claim first 10
764 claimed_count = 0
765 for task in tasks[:10]:
766 result = await claim_task(db_session, repo.repo_id, task.task_id, "batch-worker")
767 if result is not None:
768 claimed_count += 1
769 assert claimed_count == 10
770
771 @pytest.mark.asyncio
772 async def test_conflict_check_100_reserved_symbols(
773 self, db_session: AsyncSession
774 ) -> None:
775 from musehub.services.musehub_coord import coord_push
776 from musehub.services.musehub_coord_server import conflict_check
777 from musehub.db import coord_models as _cm
778
779 repo = await create_repo(db_session, slug="stress-conflict-100")
780 # Insert 100 reservations directly
781 exp = _now() + timedelta(seconds=300)
782 for i in range(100):
783 row = _cm.MusehubCoordReservation(
784 reservation_id=_uuid(),
785 repo_id=repo.repo_id,
786 symbol_address=f"module/file_{i}.py::Fn{i}",
787 agent_id=f"agent-{i}",
788 ttl_s=300,
789 created_at=_now(),
790 expires_at=exp,
791 )
792 db_session.add(row)
793 await db_session.commit()
794
795 # Check the last 50 — all should conflict
796 addresses = [f"module/file_{i}.py::Fn{i}" for i in range(50, 100)]
797 conflicts = await conflict_check(db_session, repo.repo_id, addresses)
798 assert len(conflicts) == 50
799
800
801 # ===========================================================================
802 # Layer 5 — Data Integrity tests
803 # ===========================================================================
804
805 class TestDataIntegrity:
806 @pytest.mark.asyncio
807 async def test_write_once_constraint_enforced(self, db_session: AsyncSession) -> None:
808 """The UniqueConstraint on (repo_id, kind, record_uuid) must hold."""
809 from musehub.services.musehub_coord import coord_push
810
811 repo = await create_repo(db_session, slug="di-unique-constraint")
812 uid = _uuid()
813 rec = _record("intent", record_uuid=uid)
814
815 r1 = await coord_push(db_session, repo.repo_id, CoordPushRequest(records=[rec]))
816 r2 = await coord_push(db_session, repo.repo_id, CoordPushRequest(records=[rec]))
817 # First → inserted, second → skipped (not error)
818 assert r1.inserted == 1
819 assert r2.skipped == 1
820
821 @pytest.mark.asyncio
822 async def test_heartbeat_upsert_does_not_create_new_row(
823 self, db_session: AsyncSession
824 ) -> None:
825 from musehub.services.musehub_coord import coord_push, coord_pull
826
827 repo = await create_repo(db_session, slug="di-hb-no-dup")
828 uid = _uuid()
829 for i in range(5):
830 rec = _record("heartbeat", record_uuid=uid, payload={"tick": i})
831 await coord_push(db_session, repo.repo_id, CoordPushRequest(records=[rec]))
832
833 resp = await coord_pull(db_session, repo.repo_id,
834 CoordPollRequest(kinds=["heartbeat"]))
835 # Only 1 row despite 5 pushes
836 assert len(resp.records) == 1
837 assert resp.records[0].payload["tick"] == 4
838
839 @pytest.mark.asyncio
840 async def test_coord_record_fields_complete(self, db_session: AsyncSession) -> None:
841 from musehub.services.musehub_coord import coord_push, coord_pull
842
843 repo = await create_repo(db_session, slug="di-record-fields")
844 uid = _uuid()
845 exp = _now() + timedelta(seconds=120)
846 rec = _record("dependency", record_uuid=uid, run_id="run-99",
847 payload={"dep": "x"}, expires_at=exp)
848 await coord_push(db_session, repo.repo_id, CoordPushRequest(records=[rec]))
849
850 resp = await coord_pull(db_session, repo.repo_id,
851 CoordPollRequest(kinds=["dependency"]))
852 r = resp.records[0]
853 assert r.kind == "dependency"
854 assert r.record_uuid == uid
855 assert r.run_id == "run-99"
856 assert r.payload == {"dep": "x"}
857 assert r.repo_id == repo.repo_id
858 assert r.created_at is not None
859
860 @pytest.mark.asyncio
861 async def test_task_depends_on_preserved(self, db_session: AsyncSession) -> None:
862 from musehub.services.musehub_coord import coord_push
863 from musehub.services.musehub_coord_server import list_tasks
864
865 repo = await create_repo(db_session, slug="di-depends-on")
866 dep_a = _uuid()
867 dep_b = _uuid()
868 task_id = _uuid()
869 payload = {"task_id": task_id, "queue": "default", "depends_on": [dep_a, dep_b]}
870 await coord_push(db_session, repo.repo_id,
871 CoordPushRequest(records=[_record("task", record_uuid=task_id,
872 payload=payload)]))
873 tasks = await list_tasks(db_session, repo.repo_id)
874 assert tasks[0].depends_on == [dep_a, dep_b]
875
876 @pytest.mark.asyncio
877 async def test_release_marks_reservation_released(self, db_session: AsyncSession) -> None:
878 from musehub.services.musehub_coord import coord_push
879 from musehub.services.musehub_coord_server import list_reservations
880 from musehub.db import coord_models as _cm
881
882 repo = await create_repo(db_session, slug="di-release")
883 res_id = _uuid()
884 exp = _now() + timedelta(seconds=300)
885 res_payload = {
886 "reservation_id": res_id,
887 "run_id": "agent-r",
888 "addresses": ["c.py::Fn"],
889 "ttl_s": 300,
890 "expires_at": exp.isoformat(),
891 }
892 await coord_push(db_session, repo.repo_id,
893 CoordPushRequest(records=[_record("reservation", record_uuid=res_id,
894 payload=res_payload,
895 expires_at=exp)]))
896
897 # Confirm reservation exists
898 active = await list_reservations(db_session, repo.repo_id)
899 assert len(active) == 1
900
901 # Push a release record
902 rel_id = _uuid()
903 rel_payload = {"reservation_id": res_id}
904 await coord_push(db_session, repo.repo_id,
905 CoordPushRequest(records=[_record("release", record_uuid=rel_id,
906 payload=rel_payload)]))
907
908 # Reservation should now be gone from active list
909 active_after = await list_reservations(db_session, repo.repo_id)
910 assert len(active_after) == 0
911
912
913 # ===========================================================================
914 # Layer 6 — Security tests
915 # ===========================================================================
916
917 class TestSecurity:
918 @pytest.mark.asyncio
919 async def test_push_requires_authentication(
920 self, client: AsyncClient, db_session: AsyncSession
921 ) -> None:
922 repo = await create_repo(db_session, slug="sec-push-noauth", visibility="public")
923 await db_session.commit()
924 resp = await client.post(
925 f"/{repo.owner}/{repo.slug}/coord/push",
926 json=_push_body(_record()),
927 )
928 assert resp.status_code == 401
929
930 @pytest.mark.asyncio
931 async def test_push_403_for_non_owner_authenticated(
932 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
933 ) -> None:
934 repo = await create_repo(db_session, slug="sec-push-nonowner",
935 owner_user_id="other-owner-id")
936 await db_session.commit()
937 resp = await client.post(
938 f"/{repo.owner}/{repo.slug}/coord/push",
939 json=_push_body(_record()),
940 headers=auth_headers,
941 )
942 assert resp.status_code == 403
943
944 @pytest.mark.asyncio
945 async def test_private_repo_pull_returns_404_unauthenticated(
946 self, client: AsyncClient, db_session: AsyncSession
947 ) -> None:
948 repo = await create_repo(db_session, slug="sec-priv-pull", visibility="private")
949 await db_session.commit()
950 resp = await client.post(
951 f"/{repo.owner}/{repo.slug}/coord/pull",
952 json=_pull_body(),
953 )
954 assert resp.status_code == 404
955
956 @pytest.mark.asyncio
957 async def test_push_invalid_kind_rejected(
958 self, client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
959 ) -> None:
960 from tests.conftest import _TEST_IDENTITY_ID
961 repo = await create_repo(db_session, slug="sec-invalid-kind",
962 owner_user_id=_TEST_IDENTITY_ID)
963 await db_session.commit()
964 bad_payload = {
965 "records": [{
966 "kind": "INJECT_SQL",
967 "record_uuid": str(uuid.uuid4()),
968 "run_id": "",
969 "payload": {},
970 }]
971 }
972 resp = await client.post(
973 f"/{repo.owner}/{repo.slug}/coord/push",
974 json=bad_payload,
975 headers=auth_headers,
976 )
977 assert resp.status_code == 422
978
979 @pytest.mark.asyncio
980 async def test_watch_invalid_kind_query_param_400(
981 self, client: AsyncClient, db_session: AsyncSession
982 ) -> None:
983 repo = await create_repo(db_session, slug="sec-watch-kind", visibility="public")
984 await db_session.commit()
985 resp = await client.get(
986 f"/{repo.owner}/{repo.slug}/coord/watch",
987 params={"kinds": "evil_kind"},
988 )
989 assert resp.status_code == 400
990
991 @pytest.mark.asyncio
992 async def test_complete_task_wrong_agent_rejected(
993 self, db_session: AsyncSession
994 ) -> None:
995 from musehub.services.musehub_coord import coord_push
996 from musehub.services.musehub_coord_server import claim_task, complete_task
997
998 repo = await create_repo(db_session, slug="sec-complete-wrong-agent")
999 task_id = _uuid()
1000 payload = {"task_id": task_id, "queue": "default"}
1001 await coord_push(db_session, repo.repo_id,
1002 CoordPushRequest(records=[_record("task", record_uuid=task_id,
1003 payload=payload)]))
1004
1005 await claim_task(db_session, repo.repo_id, task_id, "worker-A")
1006 result = await complete_task(db_session, repo.repo_id, task_id, "worker-B")
1007 # worker-B did not claim it — must return None
1008 assert result is None
1009
1010
1011 # ===========================================================================
1012 # Layer 7 — Performance tests
1013 # ===========================================================================
1014
1015 class TestPerformance:
1016 @pytest.mark.asyncio
1017 async def test_push_100_records_under_500ms(self, db_session: AsyncSession) -> None:
1018 from musehub.services.musehub_coord import coord_push
1019
1020 repo = await create_repo(db_session, slug="perf-push-100")
1021 records = [_record("intent") for _ in range(100)]
1022 req = CoordPushRequest(records=records)
1023
1024 t0 = time.perf_counter()
1025 resp = await coord_push(db_session, repo.repo_id, req)
1026 elapsed_ms = (time.perf_counter() - t0) * 1000
1027
1028 assert resp.inserted == 100
1029 assert elapsed_ms < 500, f"push 100 records took {elapsed_ms:.1f}ms"
1030
1031 @pytest.mark.asyncio
1032 async def test_pull_500_records_under_200ms(self, db_session: AsyncSession) -> None:
1033 from musehub.services.musehub_coord import coord_push, coord_pull
1034
1035 repo = await create_repo(db_session, slug="perf-pull-500")
1036 records = [_record("dependency") for _ in range(500)]
1037 await coord_push(db_session, repo.repo_id, CoordPushRequest(records=records))
1038
1039 t0 = time.perf_counter()
1040 resp = await coord_pull(db_session, repo.repo_id, CoordPollRequest(limit=1000))
1041 elapsed_ms = (time.perf_counter() - t0) * 1000
1042
1043 assert len(resp.records) == 500
1044 assert elapsed_ms < 200, f"pull 500 records took {elapsed_ms:.1f}ms"
1045
1046 @pytest.mark.asyncio
1047 async def test_conflict_check_50_addresses_under_100ms(
1048 self, db_session: AsyncSession
1049 ) -> None:
1050 from musehub.services.musehub_coord_server import conflict_check
1051 from musehub.db import coord_models as _cm
1052
1053 repo = await create_repo(db_session, slug="perf-conflict")
1054 exp = _now() + timedelta(seconds=300)
1055 for i in range(50):
1056 db_session.add(_cm.MusehubCoordReservation(
1057 reservation_id=_uuid(),
1058 repo_id=repo.repo_id,
1059 symbol_address=f"pkg/file_{i}.py::Fn{i}",
1060 agent_id="agent",
1061 ttl_s=300,
1062 created_at=_now(),
1063 expires_at=exp,
1064 ))
1065 await db_session.commit()
1066
1067 addresses = [f"pkg/file_{i}.py::Fn{i}" for i in range(50)]
1068 t0 = time.perf_counter()
1069 conflicts = await conflict_check(db_session, repo.repo_id, addresses)
1070 elapsed_ms = (time.perf_counter() - t0) * 1000
1071
1072 assert len(conflicts) == 50
1073 assert elapsed_ms < 100, f"conflict_check 50 addresses took {elapsed_ms:.1f}ms"
1074
1075 @pytest.mark.asyncio
1076 async def test_task_queue_list_100_under_100ms(self, db_session: AsyncSession) -> None:
1077 from musehub.services.musehub_coord import coord_push
1078 from musehub.services.musehub_coord_server import list_tasks
1079
1080 repo = await create_repo(db_session, slug="perf-tasklist-100")
1081 for _ in range(100):
1082 tid = _uuid()
1083 await coord_push(db_session, repo.repo_id,
1084 CoordPushRequest(records=[_record("task", record_uuid=tid,
1085 payload={"task_id": tid,
1086 "queue": "perf"})]))
1087
1088 t0 = time.perf_counter()
1089 tasks = await list_tasks(db_session, repo.repo_id, limit=100)
1090 elapsed_ms = (time.perf_counter() - t0) * 1000
1091
1092 assert len(tasks) == 100
1093 assert elapsed_ms < 100, f"list_tasks 100 took {elapsed_ms:.1f}ms"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago