gabriel / musehub public
test_mcp_profile_tools.py python
792 lines 32.7 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """TDD tests for Phase 5 MCP profile + attestation + MPay executor functions.
2
3 Tests are written RED-first against the public contracts described in issue #2.
4 They drive the implementation of six new executor functions and their dispatcher
5 routing.
6
7 Covered executors:
8 1. execute_read_profile_manifest — full archetype-aware manifest
9 2. execute_issue_attestation — verify sig + persist
10 3. execute_revoke_attestation — revoke by id, attester-only
11 4. execute_list_attestations — query by subject
12 5. execute_record_mpay_claim — verify sig + persist, idempotent
13 6. execute_get_mpay_ledger — sent + received totals
14 """
15 from __future__ import annotations
16
17 from datetime import datetime, timezone
18 from unittest.mock import AsyncMock, MagicMock, patch
19
20 import pytest
21 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
22 from muse.core.types import encode_pubkey, encode_sig, long_id
23
24 # ---------------------------------------------------------------------------
25 # Helpers — shared across all test groups
26 # ---------------------------------------------------------------------------
27
28 def _make_ed25519_pair() -> tuple[Ed25519PrivateKey, str]:
29 privkey = Ed25519PrivateKey.generate()
30 pub_bytes = privkey.public_key().public_bytes_raw()
31 return privkey, encode_pubkey("ed25519", pub_bytes)
32
33
34 def _sign_attest(privkey: Ed25519PrivateKey, attester: str, subject: str, claim: str, ts: str) -> str:
35 msg = f"ATTEST\n{attester}\n{subject}\n{claim}\n{ts}".encode()
36 return encode_sig("ed25519", privkey.sign(msg))
37
38
39 def _sign_mpay(privkey: Ed25519PrivateKey, sender: str, recipient: str, amount: int, nonce: str) -> str:
40 msg = f"MPAY\n{sender}\n{recipient}\n{amount}\n{nonce}".encode()
41 return encode_sig("ed25519", privkey.sign(msg))
42
43
44 _ATTESTER = "gabriel"
45 _SUBJECT = "aria"
46 _CLAIM = '{"type": "human", "confidence": 0.99}'
47 _TS = "2026-04-21T12:00:00+00:00"
48 _SENDER = "gabriel"
49 _RECIPIENT = "aria"
50 _AMOUNT_NANO = 500_000
51 _NONCE_HEX = "b" * 64
52
53
54 # ---------------------------------------------------------------------------
55 # 1. execute_read_profile_manifest
56 # ---------------------------------------------------------------------------
57
58 class TestExecuteReadProfileManifest:
59 """execute_read_profile_manifest must return the full archetype-aware manifest,
60 not just basic bio/avatar fields."""
61
62 def _make_manifest(self, identity_type: str = "human") -> MagicMock:
63 from musehub.models.musehub import (
64 ActivityDomain, AttestationBadge, OrgManifest,
65 ProfileManifest, ProfileRepoSummary, TrustChainEntry,
66 )
67 now = datetime.now(timezone.utc)
68 return ProfileManifest(
69 identity_id=long_id("a" * 64),
70 handle="gabriel",
71 identity_type=identity_type,
72 display_name="Carlos Gabriel Cardona",
73 bio="Building the sound of the future",
74 avatar_url="https://staging.musehub.ai/avatars/gabriel.png",
75 location="San Francisco",
76 website_url="https://gabriel.dev",
77 social_url="https://x.com/gabriel",
78 is_verified=True,
79 pinned_repo_ids=[],
80 repos=[],
81 created_at=now,
82 updated_at=now,
83 activity=[
84 ActivityDomain(domain="code", grid=[0] * 364, peak=0, total=0),
85 ActivityDomain(domain="music", grid=[3] * 364, peak=3, total=3 * 364),
86 ],
87 attestations=[
88 AttestationBadge(
89 attestation_id=long_id("b" * 64),
90 attester="aaronrene",
91 subject="gabriel",
92 claim_type="collab",
93 issued_at=now,
94 )
95 ],
96 avax_address="0x1a2b3c4d5e6f" if identity_type == "human" else None,
97 agent_model="claude-sonnet-4-6" if identity_type == "agent" else None,
98 agent_capabilities=["read:repos", "write:repos"] if identity_type == "agent" else [],
99 trust_chain=[TrustChainEntry(handle="gabriel", identity_type="human")] if identity_type == "agent" else [],
100 org=OrgManifest(members=["gabriel", "aria"], quorum=2) if identity_type == "org" else None,
101 mpay_total_sent_nano=1_000_000,
102 mpay_total_received_nano=500_000,
103 )
104
105 @pytest.mark.asyncio
106 async def test_returns_full_manifest_fields(self) -> None:
107 """Result data must include identity_type, activity, attestations, avax_address, mpay totals."""
108 from musehub.services.musehub_mcp_executor import execute_read_profile_manifest
109
110 manifest = self._make_manifest("human")
111
112 with (
113 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
114 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
115 patch("musehub.services.musehub_profile.build_profile_manifest", new=AsyncMock(return_value=manifest)),
116 patch("musehub.services.musehub_attestations.get_attestations_for_subject", new=AsyncMock()),
117 patch("musehub.services.musehub_attestations.attestation_to_badge", return_value=manifest.attestations[0]),
118 patch("musehub.services.musehub_mpay.get_mpay_ledger", new=AsyncMock()),
119 ):
120 mock_session = AsyncMock()
121 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
122 mock_session.__aexit__ = AsyncMock(return_value=False)
123 mock_session_cls.return_value = mock_session
124
125 result = await execute_read_profile_manifest(handle="gabriel")
126
127 assert result.ok is True
128 d = result.data
129 assert d is not None
130 assert d["handle"] == "gabriel"
131 assert d["identity_type"] == "human"
132 assert "activity" in d
133 assert "attestations" in d
134 assert d["avax_address"] == "0x1a2b3c4d5e6f"
135 assert d["mpay_total_sent_nano"] == 1_000_000
136 assert d["mpay_total_received_nano"] == 500_000
137
138 @pytest.mark.asyncio
139 async def test_returns_agent_specific_fields(self) -> None:
140 """Agent manifest includes agent_model, agent_capabilities, trust_chain."""
141 from musehub.services.musehub_mcp_executor import execute_read_profile_manifest
142
143 manifest = self._make_manifest("agent")
144
145 with (
146 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
147 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
148 patch("musehub.services.musehub_profile.build_profile_manifest", new=AsyncMock(return_value=manifest)),
149 patch("musehub.services.musehub_attestations.get_attestations_for_subject", new=AsyncMock()),
150 patch("musehub.services.musehub_attestations.attestation_to_badge", return_value=manifest.attestations[0]),
151 patch("musehub.services.musehub_mpay.get_mpay_ledger", new=AsyncMock()),
152 ):
153 mock_session = AsyncMock()
154 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
155 mock_session.__aexit__ = AsyncMock(return_value=False)
156 mock_session_cls.return_value = mock_session
157
158 result = await execute_read_profile_manifest(handle="mix-engine-7")
159
160 assert result.ok is True
161 d = result.data
162 assert d["identity_type"] == "agent"
163 assert d["agent_model"] == "claude-sonnet-4-6"
164 assert "read:repos" in d["agent_capabilities"]
165 assert len(d["trust_chain"]) == 1
166
167 @pytest.mark.asyncio
168 async def test_returns_org_specific_fields(self) -> None:
169 """Org manifest includes org manifest with members and quorum."""
170 from musehub.services.musehub_mcp_executor import execute_read_profile_manifest
171
172 manifest = self._make_manifest("org")
173
174 with (
175 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
176 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
177 patch("musehub.services.musehub_profile.build_profile_manifest", new=AsyncMock(return_value=manifest)),
178 patch("musehub.services.musehub_attestations.get_attestations_for_subject", new=AsyncMock()),
179 patch("musehub.services.musehub_attestations.attestation_to_badge", return_value=manifest.attestations[0]),
180 patch("musehub.services.musehub_mpay.get_mpay_ledger", new=AsyncMock()),
181 ):
182 mock_session = AsyncMock()
183 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
184 mock_session.__aexit__ = AsyncMock(return_value=False)
185 mock_session_cls.return_value = mock_session
186
187 result = await execute_read_profile_manifest(handle="darkroom-collective")
188
189 assert result.ok is True
190 d = result.data
191 assert d["identity_type"] == "org"
192 assert d["org"] is not None
193 assert "gabriel" in d["org"]["members"]
194 assert d["org"]["quorum"] == 2
195
196 @pytest.mark.asyncio
197 async def test_not_found_returns_error(self) -> None:
198 from musehub.services.musehub_mcp_executor import execute_read_profile_manifest
199
200 with (
201 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
202 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
203 patch("musehub.services.musehub_profile.build_profile_manifest", new=AsyncMock(return_value=None)),
204 patch("musehub.services.musehub_attestations.get_attestations_for_subject", new=AsyncMock()),
205 patch("musehub.services.musehub_mpay.get_mpay_ledger", new=AsyncMock()),
206 ):
207 mock_session = AsyncMock()
208 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
209 mock_session.__aexit__ = AsyncMock(return_value=False)
210 mock_session_cls.return_value = mock_session
211
212 result = await execute_read_profile_manifest(handle="nobody")
213
214 assert result.ok is False
215 assert result.error_code == "profile_not_found"
216
217
218 # ---------------------------------------------------------------------------
219 # 2. execute_issue_attestation
220 # ---------------------------------------------------------------------------
221
222 class TestExecuteIssueAttestation:
223 """execute_issue_attestation must verify the Ed25519 signature and persist."""
224
225 @pytest.mark.asyncio
226 async def test_valid_attestation_returns_ok(self) -> None:
227 from musehub.services.musehub_mcp_executor import execute_issue_attestation
228
229 privkey, pubkey = _make_ed25519_pair()
230 sig = _sign_attest(privkey, _ATTESTER, _SUBJECT, _CLAIM, _TS)
231
232 issued_at = datetime.fromisoformat(_TS)
233 mock_resp = MagicMock()
234 mock_resp.attestation_id = long_id("c" * 64)
235 mock_resp.attester = _ATTESTER
236 mock_resp.subject = _SUBJECT
237 mock_resp.claim = _CLAIM
238 mock_resp.issued_at = issued_at
239 mock_resp.revoked_at = None
240
241 with (
242 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
243 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
244 patch("musehub.services.musehub_attestations.issue_attestation", new=AsyncMock(return_value=mock_resp)),
245 ):
246 mock_session = AsyncMock()
247 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
248 mock_session.__aexit__ = AsyncMock(return_value=False)
249 mock_session_cls.return_value = mock_session
250
251 result = await execute_issue_attestation(
252 attester=_ATTESTER,
253 subject=_SUBJECT,
254 claim=_CLAIM,
255 issued_at_iso=_TS,
256 signature=sig,
257 attester_public_key=pubkey,
258 )
259
260 assert result.ok is True
261 d = result.data
262 assert d["attester"] == _ATTESTER
263 assert d["subject"] == _SUBJECT
264 assert d["attestation_id"].startswith("sha256:")
265 assert d["revoked_at"] is None
266
267 @pytest.mark.asyncio
268 async def test_invalid_signature_returns_error(self) -> None:
269 from musehub.services.musehub_mcp_executor import execute_issue_attestation
270
271 _, pubkey = _make_ed25519_pair()
272 bad_sig = f"ed25519:{"Z" * 88}" # garbage
273
274 with patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None):
275 result = await execute_issue_attestation(
276 attester=_ATTESTER,
277 subject=_SUBJECT,
278 claim=_CLAIM,
279 issued_at_iso=_TS,
280 signature=bad_sig,
281 attester_public_key=pubkey,
282 )
283
284 assert result.ok is False
285 assert result.error_code == "invalid_attestation_signature"
286
287 @pytest.mark.asyncio
288 async def test_wrong_key_returns_error(self) -> None:
289 """Signing key doesn't match supplied public key → verification fails."""
290 from musehub.services.musehub_mcp_executor import execute_issue_attestation
291
292 privkey, _ = _make_ed25519_pair()
293 _, other_pubkey = _make_ed25519_pair()
294 sig = _sign_attest(privkey, _ATTESTER, _SUBJECT, _CLAIM, _TS)
295
296 with patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None):
297 result = await execute_issue_attestation(
298 attester=_ATTESTER,
299 subject=_SUBJECT,
300 claim=_CLAIM,
301 issued_at_iso=_TS,
302 signature=sig,
303 attester_public_key=other_pubkey,
304 )
305
306 assert result.ok is False
307 assert result.error_code == "invalid_attestation_signature"
308
309
310 # ---------------------------------------------------------------------------
311 # 3. execute_revoke_attestation
312 # ---------------------------------------------------------------------------
313
314 class TestExecuteRevokeAttestation:
315
316 @pytest.mark.asyncio
317 async def test_revoke_returns_revoked_at(self) -> None:
318 from musehub.services.musehub_mcp_executor import execute_revoke_attestation
319
320 now = datetime.now(timezone.utc)
321 mock_resp = MagicMock()
322 mock_resp.attestation_id = long_id("d" * 64)
323 mock_resp.attester = _ATTESTER
324 mock_resp.subject = _SUBJECT
325 mock_resp.claim = _CLAIM
326 mock_resp.issued_at = datetime.fromisoformat(_TS)
327 mock_resp.revoked_at = now
328
329 with (
330 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
331 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
332 patch("musehub.services.musehub_attestations.revoke_attestation", new=AsyncMock(return_value=mock_resp)),
333 ):
334 mock_session = AsyncMock()
335 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
336 mock_session.__aexit__ = AsyncMock(return_value=False)
337 mock_session_cls.return_value = mock_session
338
339 result = await execute_revoke_attestation(
340 attestation_id=long_id("d" * 64),
341 revoker=_ATTESTER,
342 )
343
344 assert result.ok is True
345 assert result.data["revoked_at"] is not None
346
347 @pytest.mark.asyncio
348 async def test_not_found_returns_error(self) -> None:
349 from musehub.services.musehub_mcp_executor import execute_revoke_attestation
350
351 with (
352 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
353 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
354 patch(
355 "musehub.services.musehub_attestations.revoke_attestation",
356 new=AsyncMock(side_effect=KeyError("not found")),
357 ),
358 ):
359 mock_session = AsyncMock()
360 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
361 mock_session.__aexit__ = AsyncMock(return_value=False)
362 mock_session_cls.return_value = mock_session
363
364 result = await execute_revoke_attestation(
365 attestation_id=long_id("e" * 64),
366 revoker=_ATTESTER,
367 )
368
369 assert result.ok is False
370 assert result.error_code == "attestation_not_found"
371
372 @pytest.mark.asyncio
373 async def test_wrong_revoker_returns_forbidden(self) -> None:
374 from musehub.services.musehub_mcp_executor import execute_revoke_attestation
375
376 with (
377 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
378 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
379 patch(
380 "musehub.services.musehub_attestations.revoke_attestation",
381 new=AsyncMock(side_effect=PermissionError("not your attestation")),
382 ),
383 ):
384 mock_session = AsyncMock()
385 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
386 mock_session.__aexit__ = AsyncMock(return_value=False)
387 mock_session_cls.return_value = mock_session
388
389 result = await execute_revoke_attestation(
390 attestation_id=long_id("d" * 64),
391 revoker="impostor",
392 )
393
394 assert result.ok is False
395 assert result.error_code == "forbidden"
396
397
398 # ---------------------------------------------------------------------------
399 # 4. execute_list_attestations
400 # ---------------------------------------------------------------------------
401
402 class TestExecuteListAttestations:
403
404 @pytest.mark.asyncio
405 async def test_returns_list_with_count(self) -> None:
406 from musehub.services.musehub_mcp_executor import execute_list_attestations
407 from musehub.models.musehub import AttestationListResponse, AttestationResponse
408
409 now = datetime.now(timezone.utc)
410 badge = AttestationResponse(
411 attestation_id=long_id("f" * 64),
412 attester=_ATTESTER,
413 subject=_SUBJECT,
414 claim=_CLAIM,
415 signature="ed25519:abc",
416 attester_public_key="ed25519:def",
417 issued_at=now,
418 revoked_at=None,
419 )
420 mock_resp = AttestationListResponse(subject=_SUBJECT, attestations=[badge], total=1)
421
422 with (
423 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
424 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
425 patch(
426 "musehub.services.musehub_attestations.get_attestations_for_subject",
427 new=AsyncMock(return_value=mock_resp),
428 ),
429 ):
430 mock_session = AsyncMock()
431 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
432 mock_session.__aexit__ = AsyncMock(return_value=False)
433 mock_session_cls.return_value = mock_session
434
435 result = await execute_list_attestations(subject=_SUBJECT, include_revoked=False)
436
437 assert result.ok is True
438 assert result.data["subject"] == _SUBJECT
439 assert result.data["total"] == 1
440 assert len(result.data["attestations"]) == 1
441 assert result.data["attestations"][0]["attester"] == _ATTESTER
442
443 @pytest.mark.asyncio
444 async def test_empty_subject_returns_empty_list(self) -> None:
445 from musehub.services.musehub_mcp_executor import execute_list_attestations
446 from musehub.models.musehub import AttestationListResponse
447
448 mock_resp = AttestationListResponse(subject="nobody", attestations=[], total=0)
449
450 with (
451 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
452 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
453 patch(
454 "musehub.services.musehub_attestations.get_attestations_for_subject",
455 new=AsyncMock(return_value=mock_resp),
456 ),
457 ):
458 mock_session = AsyncMock()
459 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
460 mock_session.__aexit__ = AsyncMock(return_value=False)
461 mock_session_cls.return_value = mock_session
462
463 result = await execute_list_attestations(subject="nobody")
464
465 assert result.ok is True
466 assert result.data["total"] == 0
467 assert result.data["attestations"] == []
468
469
470 # ---------------------------------------------------------------------------
471 # 5. execute_record_mpay_claim
472 # ---------------------------------------------------------------------------
473
474 class TestExecuteRecordMpayClaim:
475
476 @pytest.mark.asyncio
477 async def test_valid_claim_returns_claim_id(self) -> None:
478 from musehub.services.musehub_mcp_executor import execute_record_mpay_claim
479
480 privkey, pubkey = _make_ed25519_pair()
481 sig = _sign_mpay(privkey, _SENDER, _RECIPIENT, _AMOUNT_NANO, _NONCE_HEX)
482 now = datetime.now(timezone.utc)
483
484 mock_resp = MagicMock()
485 mock_resp.claim_id = long_id("a" * 64)
486 mock_resp.sender = _SENDER
487 mock_resp.recipient = _RECIPIENT
488 mock_resp.amount_nano = _AMOUNT_NANO
489 mock_resp.nonce_hex = _NONCE_HEX
490 mock_resp.created_at = now
491 mock_resp.confirmed_at = None
492 mock_resp.voided_at = None
493 mock_resp.memo = None
494
495 with (
496 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
497 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
498 patch("musehub.services.musehub_mpay.record_mpay_claim", new=AsyncMock(return_value=mock_resp)),
499 ):
500 mock_session = AsyncMock()
501 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
502 mock_session.__aexit__ = AsyncMock(return_value=False)
503 mock_session_cls.return_value = mock_session
504
505 result = await execute_record_mpay_claim(
506 sender=_SENDER,
507 recipient=_RECIPIENT,
508 amount_nano=_AMOUNT_NANO,
509 nonce_hex=_NONCE_HEX,
510 signature=sig,
511 sender_public_key=pubkey,
512 )
513
514 assert result.ok is True
515 d = result.data
516 assert d["sender"] == _SENDER
517 assert d["recipient"] == _RECIPIENT
518 assert d["amount_nano"] == _AMOUNT_NANO
519 assert d["claim_id"].startswith("sha256:")
520
521 @pytest.mark.asyncio
522 async def test_invalid_mpay_signature_returns_error(self) -> None:
523 from musehub.services.musehub_mcp_executor import execute_record_mpay_claim
524
525 _, pubkey = _make_ed25519_pair()
526 bad_sig = f"ed25519:{"Z" * 88}"
527
528 with patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None):
529 result = await execute_record_mpay_claim(
530 sender=_SENDER,
531 recipient=_RECIPIENT,
532 amount_nano=_AMOUNT_NANO,
533 nonce_hex=_NONCE_HEX,
534 signature=bad_sig,
535 sender_public_key=pubkey,
536 )
537
538 assert result.ok is False
539 assert result.error_code == "invalid_mpay_signature"
540
541 @pytest.mark.asyncio
542 async def test_zero_amount_returns_error(self) -> None:
543 """Amount must be > 0."""
544 from musehub.services.musehub_mcp_executor import execute_record_mpay_claim
545
546 privkey, pubkey = _make_ed25519_pair()
547 sig = _sign_mpay(privkey, _SENDER, _RECIPIENT, 0, _NONCE_HEX)
548
549 with patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None):
550 result = await execute_record_mpay_claim(
551 sender=_SENDER,
552 recipient=_RECIPIENT,
553 amount_nano=0,
554 nonce_hex=_NONCE_HEX,
555 signature=sig,
556 sender_public_key=pubkey,
557 )
558
559 assert result.ok is False
560 assert result.error_code == "invalid_amount"
561
562 @pytest.mark.asyncio
563 async def test_self_payment_returns_error(self) -> None:
564 """Sender and recipient must be different handles."""
565 from musehub.services.musehub_mcp_executor import execute_record_mpay_claim
566
567 privkey, pubkey = _make_ed25519_pair()
568 sig = _sign_mpay(privkey, _SENDER, _SENDER, _AMOUNT_NANO, _NONCE_HEX)
569
570 with patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None):
571 result = await execute_record_mpay_claim(
572 sender=_SENDER,
573 recipient=_SENDER,
574 amount_nano=_AMOUNT_NANO,
575 nonce_hex=_NONCE_HEX,
576 signature=sig,
577 sender_public_key=pubkey,
578 )
579
580 assert result.ok is False
581 assert result.error_code == "self_payment"
582
583
584 # ---------------------------------------------------------------------------
585 # 6. execute_get_mpay_ledger
586 # ---------------------------------------------------------------------------
587
588 class TestExecuteGetMpayLedger:
589
590 @pytest.mark.asyncio
591 async def test_returns_sent_received_totals(self) -> None:
592 from musehub.services.musehub_mcp_executor import execute_get_mpay_ledger
593 from musehub.models.musehub import MPayLedgerResponse, MPayClaimResponse
594
595 now = datetime.now(timezone.utc)
596 claim = MPayClaimResponse(
597 claim_id=long_id("a" * 64),
598 sender=_SENDER,
599 recipient=_RECIPIENT,
600 amount_nano=_AMOUNT_NANO,
601 nonce_hex=_NONCE_HEX,
602 signature="ed25519:abc",
603 sender_public_key="ed25519:def",
604 memo=None,
605 created_at=now,
606 confirmed_at=None,
607 voided_at=None,
608 )
609 mock_ledger = MPayLedgerResponse(
610 handle=_SENDER,
611 sent=[claim],
612 received=[],
613 total_sent_nano=_AMOUNT_NANO,
614 total_received_nano=0,
615 )
616
617 with (
618 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
619 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
620 patch("musehub.services.musehub_mpay.get_mpay_ledger", new=AsyncMock(return_value=mock_ledger)),
621 ):
622 mock_session = AsyncMock()
623 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
624 mock_session.__aexit__ = AsyncMock(return_value=False)
625 mock_session_cls.return_value = mock_session
626
627 result = await execute_get_mpay_ledger(handle=_SENDER, limit=50)
628
629 assert result.ok is True
630 d = result.data
631 assert d["handle"] == _SENDER
632 assert d["total_sent_nano"] == _AMOUNT_NANO
633 assert d["total_received_nano"] == 0
634 assert len(d["sent"]) == 1
635 assert d["sent"][0]["amount_nano"] == _AMOUNT_NANO
636
637 @pytest.mark.asyncio
638 async def test_limit_clamped_to_500(self) -> None:
639 """Limit above 500 is clamped silently."""
640 from musehub.services.musehub_mcp_executor import execute_get_mpay_ledger
641 from musehub.models.musehub import MPayLedgerResponse
642
643 mock_ledger = MPayLedgerResponse(
644 handle=_SENDER, sent=[], received=[],
645 total_sent_nano=0, total_received_nano=0,
646 )
647
648 captured: dict[str, int] = {}
649
650 async def _mock_ledger(db, handle, limit=100):
651 captured["limit"] = limit
652 return mock_ledger
653
654 with (
655 patch("musehub.services.musehub_mcp_executor._check_db_available", return_value=None),
656 patch("musehub.services.musehub_mcp_executor.AsyncSessionLocal") as mock_session_cls,
657 patch("musehub.services.musehub_mpay.get_mpay_ledger", new=_mock_ledger),
658 ):
659 mock_session = AsyncMock()
660 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
661 mock_session.__aexit__ = AsyncMock(return_value=False)
662 mock_session_cls.return_value = mock_session
663
664 await execute_get_mpay_ledger(handle=_SENDER, limit=9999)
665
666 assert captured["limit"] <= 500
667
668
669 # ---------------------------------------------------------------------------
670 # 7. Dispatcher routing
671 # ---------------------------------------------------------------------------
672
673 class TestDispatcherRouting:
674 """Dispatcher must route the 6 new tool names to the correct executors."""
675
676 @pytest.mark.asyncio
677 async def test_dispatch_read_profile_manifest(self) -> None:
678 from musehub.mcp.dispatcher import dispatch_tool
679
680 mock_result = MagicMock(ok=True, data={"handle": "gabriel"}, error_code=None, error_message=None, hint=None)
681 with patch(
682 "musehub.services.musehub_mcp_executor.execute_read_profile_manifest",
683 new=AsyncMock(return_value=mock_result),
684 ) as mock_exe:
685 await dispatch_tool(
686 name="musehub_read_profile_manifest",
687 arguments={"handle": "gabriel"},
688 user_id="gabriel",
689 session_context=None,
690 )
691 mock_exe.assert_called_once_with(handle="gabriel")
692
693 @pytest.mark.asyncio
694 async def test_dispatch_issue_attestation(self) -> None:
695 from musehub.mcp.dispatcher import dispatch_tool
696
697 mock_result = MagicMock(ok=True, data={}, error_code=None, error_message=None, hint=None)
698 with patch(
699 "musehub.services.musehub_mcp_executor.execute_issue_attestation",
700 new=AsyncMock(return_value=mock_result),
701 ) as mock_exe:
702 args = {
703 "attester": "gabriel",
704 "subject": "aria",
705 "claim": '{"type":"human"}',
706 "issued_at_iso": _TS,
707 "signature": "ed25519:abc",
708 "attester_public_key": "ed25519:def",
709 }
710 await dispatch_tool(
711 name="musehub_issue_attestation",
712 arguments=args,
713 user_id="gabriel",
714 session_context=None,
715 )
716 mock_exe.assert_called_once()
717
718 @pytest.mark.asyncio
719 async def test_dispatch_revoke_attestation(self) -> None:
720 from musehub.mcp.dispatcher import dispatch_tool
721
722 mock_result = MagicMock(ok=True, data={}, error_code=None, error_message=None, hint=None)
723 with patch(
724 "musehub.services.musehub_mcp_executor.execute_revoke_attestation",
725 new=AsyncMock(return_value=mock_result),
726 ) as mock_exe:
727 await dispatch_tool(
728 name="musehub_revoke_attestation",
729 arguments={"attestation_id": long_id("a" * 64), "revoker": "gabriel"},
730 user_id="gabriel",
731 session_context=None,
732 )
733 mock_exe.assert_called_once()
734
735 @pytest.mark.asyncio
736 async def test_dispatch_list_attestations(self) -> None:
737 from musehub.mcp.dispatcher import dispatch_tool
738
739 mock_result = MagicMock(ok=True, data={}, error_code=None, error_message=None, hint=None)
740 with patch(
741 "musehub.services.musehub_mcp_executor.execute_list_attestations",
742 new=AsyncMock(return_value=mock_result),
743 ) as mock_exe:
744 await dispatch_tool(
745 name="musehub_list_attestations",
746 arguments={"subject": "aria"},
747 user_id="gabriel",
748 session_context=None,
749 )
750 mock_exe.assert_called_once_with(subject="aria", include_revoked=False)
751
752 @pytest.mark.asyncio
753 async def test_dispatch_record_mpay_claim(self) -> None:
754 from musehub.mcp.dispatcher import dispatch_tool
755
756 mock_result = MagicMock(ok=True, data={}, error_code=None, error_message=None, hint=None)
757 with patch(
758 "musehub.services.musehub_mcp_executor.execute_record_mpay_claim",
759 new=AsyncMock(return_value=mock_result),
760 ) as mock_exe:
761 args = {
762 "sender": _SENDER,
763 "recipient": _RECIPIENT,
764 "amount_nano": _AMOUNT_NANO,
765 "nonce_hex": _NONCE_HEX,
766 "signature": "ed25519:abc",
767 "sender_public_key": "ed25519:def",
768 }
769 await dispatch_tool(
770 name="musehub_record_mpay_claim",
771 arguments=args,
772 user_id="gabriel",
773 session_context=None,
774 )
775 mock_exe.assert_called_once()
776
777 @pytest.mark.asyncio
778 async def test_dispatch_get_mpay_ledger(self) -> None:
779 from musehub.mcp.dispatcher import dispatch_tool
780
781 mock_result = MagicMock(ok=True, data={}, error_code=None, error_message=None, hint=None)
782 with patch(
783 "musehub.services.musehub_mcp_executor.execute_get_mpay_ledger",
784 new=AsyncMock(return_value=mock_result),
785 ) as mock_exe:
786 await dispatch_tool(
787 name="musehub_get_mpay_ledger",
788 arguments={"handle": "gabriel", "limit": 50},
789 user_id="gabriel",
790 session_context=None,
791 )
792 mock_exe.assert_called_once_with(handle="gabriel", limit=50)
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago