"""TDD — proposal comment schema additions (migration 0069). Fields added to musehub_proposal_comments: author_user_id — content-addressed identity ID; enables sigil without DB lookup and is resilient to handle changes agent_id — AI authorship provenance (empty string = human) model_id — which model authored the comment updated_at — set on edit; None = never edited is_deleted — soft-delete flag (consistent with issue comments) Tests: CS-1 New comments persist all five new fields CS-2 author_user_id backfill populates existing rows from musehub_identities CS-3 Human comments have empty agent_id and model_id CS-4 ProposalCommentResponse includes author_user_id CS-5 is_deleted=True excludes the comment from list results """ from __future__ import annotations import datetime import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id from musehub.core.genesis import compute_comment_id, compute_identity_id, compute_repo_id from musehub.db.musehub_repo_models import MusehubCommitRef from musehub.db.musehub_social_models import MusehubProposal, MusehubProposalComment from tests.factories import create_repo # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _now() -> datetime.datetime: return datetime.datetime.now(tz=datetime.timezone.utc) async def _make_comment( session: AsyncSession, repo_id: str, proposal_id: str, author: str = "gabriel", author_user_id: str | None = None, agent_id: str = "", model_id: str = "", body: str = "test comment", is_deleted: bool = False, ) -> MusehubProposalComment: comment_id = compute_comment_id(proposal_id, author, _now().isoformat()) c = MusehubProposalComment( comment_id=comment_id, proposal_id=proposal_id, repo_id=repo_id, author=author, author_user_id=author_user_id, agent_id=agent_id, model_id=model_id, body=body, is_deleted=is_deleted, created_at=_now(), ) session.add(c) await session.flush() return c async def _make_proposal(session: AsyncSession, repo_id: str) -> MusehubProposal: from musehub.core.genesis import compute_proposal_id author_id = compute_identity_id(b"gabriel") pid = compute_proposal_id(repo_id, author_id, "feat/x", "dev", _now().isoformat()) p = MusehubProposal( proposal_id=pid, repo_id=repo_id, proposal_number=1, title="test proposal", body="", from_branch="feat/x", to_branch="dev", author="gabriel", state="open", created_at=_now(), updated_at=_now(), ) session.add(p) await session.flush() return p # --------------------------------------------------------------------------- # CS-1 — new fields persisted # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_cs1_new_fields_persisted(db_session: AsyncSession) -> None: """All five new fields are written and read back correctly.""" repo = await create_repo(db_session) proposal = await _make_proposal(db_session, repo.repo_id) user_id = compute_identity_id(b"gabriel") comment = await _make_comment( db_session, repo_id=repo.repo_id, proposal_id=proposal.proposal_id, author="gabriel", author_user_id=user_id, agent_id="claude-code", model_id="claude-sonnet-4-6", is_deleted=False, ) await db_session.commit() row = await db_session.get(MusehubProposalComment, comment.comment_id) assert row is not None assert row.author_user_id == user_id assert row.agent_id == "claude-code" assert row.model_id == "claude-sonnet-4-6" assert row.updated_at is None assert row.is_deleted is False # --------------------------------------------------------------------------- # CS-2 — backfill author_user_id from musehub_identities # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_cs2_backfill_author_user_id(db_session: AsyncSession) -> None: """Backfill populates author_user_id for existing rows where it is NULL.""" from musehub.db.musehub_identity_models import MusehubIdentity repo = await create_repo(db_session) proposal = await _make_proposal(db_session, repo.repo_id) user_id = compute_identity_id(b"gabriel") # Insert identity row so backfill can look it up. identity = MusehubIdentity( identity_id=user_id, handle="gabriel", ) db_session.add(identity) # Comment with no author_user_id (simulates pre-migration row). comment_id = compute_comment_id(proposal.proposal_id, "gabriel", _now().isoformat()) c = MusehubProposalComment( comment_id=comment_id, proposal_id=proposal.proposal_id, repo_id=repo.repo_id, author="gabriel", author_user_id=None, # pre-migration body="old comment", created_at=_now(), ) db_session.add(c) await db_session.flush() # Run backfill. from musehub.services.musehub_proposals import backfill_comment_author_user_ids updated = await backfill_comment_author_user_ids(db_session, repo_id=repo.repo_id) await db_session.commit() row = await db_session.get(MusehubProposalComment, comment_id) assert row.author_user_id == user_id, ( f"backfill must populate author_user_id from musehub_identities; got {row.author_user_id!r}" ) assert updated >= 1 # --------------------------------------------------------------------------- # CS-3 — human comments have empty agent_id / model_id # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_cs3_human_comments_have_empty_provenance(db_session: AsyncSession) -> None: """Human-authored comments default to empty agent_id and model_id.""" repo = await create_repo(db_session) proposal = await _make_proposal(db_session, repo.repo_id) comment = await _make_comment( db_session, repo.repo_id, proposal.proposal_id, # no agent_id / model_id — human defaults ) row = await db_session.get(MusehubProposalComment, comment.comment_id) assert row.agent_id == "" or row.agent_id is None assert row.model_id == "" or row.model_id is None # --------------------------------------------------------------------------- # CS-4 — ProposalCommentResponse includes author_user_id # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_cs4_response_includes_author_user_id(db_session: AsyncSession) -> None: """list_proposal_comments returns author_user_id on each comment.""" from musehub.services import musehub_proposals repo = await create_repo(db_session) proposal = await _make_proposal(db_session, repo.repo_id) user_id = compute_identity_id(b"gabriel") await _make_comment( db_session, repo.repo_id, proposal.proposal_id, author="gabriel", author_user_id=user_id, ) await db_session.flush() result = await musehub_proposals.list_proposal_comments( db_session, proposal.proposal_id, repo.repo_id ) assert result.total == 1 comment = result.comments[0] assert hasattr(comment, "author_user_id"), "ProposalCommentResponse must expose author_user_id" assert comment.author_user_id == user_id # --------------------------------------------------------------------------- # CS-5 — is_deleted=True excluded from list # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_cs5_deleted_comments_excluded_from_list(db_session: AsyncSession) -> None: """Comments with is_deleted=True do not appear in list results.""" from musehub.services import musehub_proposals repo = await create_repo(db_session) proposal = await _make_proposal(db_session, repo.repo_id) await _make_comment(db_session, repo.repo_id, proposal.proposal_id, body="visible") await _make_comment(db_session, repo.repo_id, proposal.proposal_id, body="deleted", is_deleted=True) await db_session.flush() result = await musehub_proposals.list_proposal_comments( db_session, proposal.proposal_id, repo.repo_id ) assert result.total == 1 assert result.comments[0].body == "visible"