diff --git a/app/serializers.py b/app/serializers.py index 5d385b10..10276a5f 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -127,10 +127,20 @@ def _bounty_detail_url(bounty_id: int | None) -> str | None: return f"/bounties/{bounty_id}" if bounty_id is not None else None -def _activity_row(entry: LedgerEntry, proof: Proof) -> dict[str, Any] | None: - data = json.loads(proof.public_json) +def _proof_payload(proof: Proof) -> dict[str, Any] | None: + try: + data = json.loads(proof.public_json) + except (TypeError, json.JSONDecodeError): + return None if not isinstance(data, dict) or data.get("kind") != "bounty_payment": return None + return data + + +def _activity_row(entry: LedgerEntry, proof: Proof) -> dict[str, Any] | None: + data = _proof_payload(proof) + if data is None: + return None submission_url = str(data.get("submission_url") or entry.reference) repo = data.get("repo") issue_number = data.get("issue_number") @@ -288,8 +298,8 @@ def accepted_work_for_account(session: Session, account: str) -> list[dict[str, ).all() accepted_work: list[dict[str, Any]] = [] for proof, entry in rows: - data = json.loads(proof.public_json) - if not isinstance(data, dict) or data.get("kind") != "bounty_payment": + data = _proof_payload(proof) + if data is None: continue repo = data.get("repo") issue_number = data.get("issue_number") diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 0481ab35..0a0ab0cf 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -4,17 +4,26 @@ from app.db import create_schema, session_scope from app.ledger.service import create_bounty, ensure_genesis, pay_bounty, register_wallet -from app.models import Bounty, WalletTransfer +from app.models import Bounty, Proof, WalletTransfer from app.serializers import ( accepted_work_for_account, account_accepted_summary, + activity_to_dict, bounty_list_summary, bounty_to_dict, + empty_accepted_summary, + safe_accepted_work_for_account, + safe_account_accepted_summary, wallet_to_dict, wallet_transfer_to_dict, ) +class BrokenSession: + def execute(self, *args, **kwargs): + raise RuntimeError("database unavailable") + + def test_bounty_serializers_preserve_public_capacity_fields(sqlite_url: str) -> None: create_schema(sqlite_url) with session_scope(sqlite_url) as session: @@ -83,6 +92,52 @@ def test_account_and_wallet_serializers_preserve_public_shapes(sqlite_url: str) assert wallet_data["next_nonce"] == 1 +def test_activity_serializer_fallbacks_keep_account_schema() -> None: + assert ( + safe_account_accepted_summary(BrokenSession(), "github:alice") == empty_accepted_summary() + ) + assert safe_accepted_work_for_account(BrokenSession(), "github:alice") == [] + + +def test_activity_serializers_skip_malformed_public_proofs(sqlite_url: str) -> None: + create_schema(sqlite_url) + with session_scope(sqlite_url) as session: + ensure_genesis(session) + bounty = create_bounty( + session, + repo="ramimbo/mergework", + issue_number=320, + issue_url="https://github.com/ramimbo/mergework/issues/320", + title="Extract public serializers", + reward_mrwk="40", + acceptance="Malformed proof payloads should not break activity serializers.", + ) + proof = pay_bounty( + session, + bounty_id=bounty.id, + to_account="github:tatelyman", + submission_url="https://github.com/ramimbo/mergework/pull/330", + accepted_by="ramimbo", + verifier_result={"label": "mrwk:accepted"}, + ) + proof_row = session.get(Proof, proof.hash) + assert proof_row is not None + proof_row.public_json = "{" + + activity = activity_to_dict(session) + summary = account_accepted_summary(session, "github:tatelyman") + accepted_work = accepted_work_for_account(session, "github:tatelyman") + + assert activity == { + "totals": {"accepted_awards": 0, "accepted_mrwk": "0", "contributors": 0}, + "query": "", + "contributors": [], + "recent": [], + } + assert summary == empty_accepted_summary() + assert accepted_work == [] + + def test_wallet_transfer_serializer_normalizes_aware_timestamp() -> None: transfer = WalletTransfer( hash="abc123",