"""TDD — PATCH /repos/{repo_id}/proposals/{proposal_id} Layer 10 — Proposal Update (CRUD) T10.1 Author can update title. T10.2 Author can update body. T10.3 Author can update proposal_type. T10.4 Author can update merge_strategy. T10.5 Partial update — only supplied fields change, others are untouched. T10.6 Non-author (different authenticated user) cannot update — 403. T10.7 Unauthenticated request cannot update — 401. T10.8 Update a non-existent proposal returns 404. T10.9 Empty patch body (no fields) returns 422. """ from __future__ import annotations from datetime import datetime, timezone import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from musehub.core.genesis import compute_identity_id, compute_proposal_id, compute_repo_id from musehub.db.musehub_repo_models import MusehubRepo from musehub.db.musehub_social_models import MusehubProposal from musehub.types.json_types import StrDict from muse.core.types import now_utc_iso _seq = 0 async def _make_repo(db: AsyncSession, owner: str, slug: str) -> str: created_at = datetime.now(tz=timezone.utc) owner_id = compute_identity_id(owner.encode()) repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat()) db.add(MusehubRepo( repo_id=repo_id, name=slug, owner=owner, slug=slug, visibility="public", owner_user_id=owner_id, created_at=created_at, updated_at=created_at, )) await db.commit() return str(repo_id) async def _make_proposal( db: AsyncSession, repo_id: str, *, author: str = "testuser", title: str = "Original title", body: str = "Original body.", proposal_type: str = "state_merge", merge_strategy: str = "overlay", ) -> MusehubProposal: global _seq _seq += 1 author_id = compute_identity_id(author.encode()) from_branch = f"feat/update-test-{_seq}" p = MusehubProposal( proposal_id=compute_proposal_id(repo_id, author_id, from_branch, "dev", now_utc_iso()), repo_id=repo_id, proposal_number=_seq, title=title, body=body, state="open", proposal_type=proposal_type, merge_strategy=merge_strategy, from_branch=from_branch, to_branch="dev", author=author, ) db.add(p) await db.commit() return p # --------------------------------------------------------------------------- # T10.1 — author can update title # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_T10_1_author_can_update_title( client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(db_session, "updatedev", "t101-repo") p = await _make_proposal(db_session, repo_id, title="Old title") resp = await client.patch( f"/api/repos/{repo_id}/proposals/{p.proposal_id}", json={"title": "Proposal type badges and ghost object integrity fix"}, headers=auth_headers, ) assert resp.status_code == 200 data = resp.json() assert data["title"] == "Proposal type badges and ghost object integrity fix" # --------------------------------------------------------------------------- # T10.2 — author can update body # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_T10_2_author_can_update_body( client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(db_session, "updatedev", "t102-repo") p = await _make_proposal(db_session, repo_id, body="Old body.") resp = await client.patch( f"/api/repos/{repo_id}/proposals/{p.proposal_id}", json={"body": "Updated description with more detail."}, headers=auth_headers, ) assert resp.status_code == 200 assert resp.json()["body"] == "Updated description with more detail." # --------------------------------------------------------------------------- # T10.3 — author can update proposal_type # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_T10_3_author_can_update_proposal_type( client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(db_session, "updatedev", "t103-repo") p = await _make_proposal(db_session, repo_id, proposal_type="state_merge") resp = await client.patch( f"/api/repos/{repo_id}/proposals/{p.proposal_id}", json={"proposal_type": "canonical_release"}, headers=auth_headers, ) assert resp.status_code == 200 assert resp.json()["proposalType"] == "canonical_release" # --------------------------------------------------------------------------- # T10.4 — author can update merge_strategy # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_T10_4_author_can_update_merge_strategy( client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(db_session, "updatedev", "t104-repo") p = await _make_proposal(db_session, repo_id, merge_strategy="overlay") resp = await client.patch( f"/api/repos/{repo_id}/proposals/{p.proposal_id}", json={"merge_strategy": "replay"}, headers=auth_headers, ) assert resp.status_code == 200 assert resp.json()["mergeStrategy"] == "replay" # --------------------------------------------------------------------------- # T10.5 — partial update leaves untouched fields unchanged # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_T10_5_partial_update_only_changes_supplied_fields( client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(db_session, "updatedev", "t105-repo") p = await _make_proposal( db_session, repo_id, title="Original title", body="Original body.", proposal_type="state_merge", ) resp = await client.patch( f"/api/repos/{repo_id}/proposals/{p.proposal_id}", json={"title": "New title only"}, headers=auth_headers, ) assert resp.status_code == 200 data = resp.json() assert data["title"] == "New title only" assert data["body"] == "Original body." assert data["proposalType"] == "state_merge" # --------------------------------------------------------------------------- # T10.6 — non-author cannot update (403) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_T10_6_non_author_cannot_update( client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, ) -> None: # proposal authored by "someone-else"; auth_headers authenticates as "testuser" repo_id = await _make_repo(db_session, "updatedev", "t106-repo") p = await _make_proposal(db_session, repo_id, author="someone-else") resp = await client.patch( f"/api/repos/{repo_id}/proposals/{p.proposal_id}", json={"title": "Attempted hijack"}, headers=auth_headers, ) assert resp.status_code == 403 # --------------------------------------------------------------------------- # T10.7 — unauthenticated request returns 401 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_T10_7_unauthenticated_update_returns_401( client: AsyncClient, db_session: AsyncSession, ) -> None: repo_id = await _make_repo(db_session, "updatedev", "t107-repo") p = await _make_proposal(db_session, repo_id) resp = await client.patch( f"/api/repos/{repo_id}/proposals/{p.proposal_id}", json={"title": "No auth"}, ) assert resp.status_code == 401 # --------------------------------------------------------------------------- # T10.8 — non-existent proposal returns 404 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_T10_8_missing_proposal_returns_404( client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(db_session, "updatedev", "t108-repo") fake_id = "sha256:" + "0" * 64 resp = await client.patch( f"/api/repos/{repo_id}/proposals/{fake_id}", json={"title": "Ghost"}, headers=auth_headers, ) assert resp.status_code == 404 # --------------------------------------------------------------------------- # T10.9 — empty patch body returns 422 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_T10_9_empty_patch_body_returns_422( client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(db_session, "updatedev", "t109-repo") p = await _make_proposal(db_session, repo_id) resp = await client.patch( f"/api/repos/{repo_id}/proposals/{p.proposal_id}", json={}, headers=auth_headers, ) assert resp.status_code == 422