"""TDD — merge_strategy integrity: never NULL, shown post-merge. Covers: Service — create_proposal never stores NULL merge_strategy Service — update_proposal never writes NULL merge_strategy Migration — migration 0070 renames legacy state_* values to canonical names Template — strategy row shown for merged proposals; canonical names only """ from __future__ import annotations import datetime import importlib import pathlib import sys import types import pytest import sqlalchemy as sa from sqlalchemy.ext.asyncio import AsyncSession from musehub.models.musehub import MergeStrategy # --------------------------------------------------------------------------- # Service layer — create_proposal # --------------------------------------------------------------------------- class TestCreateProposalMergeStrategyDefault: @pytest.mark.asyncio async def test_create_defaults_to_overlay(self, db_session: AsyncSession) -> None: from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch from musehub.services.musehub_proposals import create_proposal repo_id = await _make_repo(db_session) await _make_branch(db_session, repo_id, "feat/x", {}) result = await create_proposal( db_session, repo_id=repo_id, title="No strategy given", from_branch="feat/x", to_branch="main", ) assert result.merge_strategy == "overlay" @pytest.mark.asyncio async def test_create_explicit_strategy_stored(self, db_session: AsyncSession) -> None: from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch from musehub.services.musehub_proposals import create_proposal repo_id = await _make_repo(db_session) await _make_branch(db_session, repo_id, "feat/y", {}) result = await create_proposal( db_session, repo_id=repo_id, title="Explicit strategy", from_branch="feat/y", to_branch="main", merge_strategy="replay", ) assert result.merge_strategy == "replay" @pytest.mark.asyncio async def test_create_cherry_pick_strategy_stored(self, db_session: AsyncSession) -> None: from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch from musehub.services.musehub_proposals import create_proposal repo_id = await _make_repo(db_session) await _make_branch(db_session, repo_id, "feat/z", {}) result = await create_proposal( db_session, repo_id=repo_id, title="Cherry pick proposal", from_branch="feat/z", to_branch="main", merge_strategy="cherry_pick", ) assert result.merge_strategy == "cherry_pick" # --------------------------------------------------------------------------- # Service layer — update_proposal # --------------------------------------------------------------------------- class TestUpdateProposalMergeStrategyIntegrity: @pytest.mark.asyncio async def test_update_strategy_changes_value(self, db_session: AsyncSession) -> None: from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch, _make_proposal from musehub.services.musehub_proposals import update_proposal repo_id = await _make_repo(db_session) await _make_branch(db_session, repo_id, "feat/a", {}) proposal_id = await _make_proposal(db_session, repo_id, from_branch="feat/a") updated = await update_proposal( db_session, repo_id, proposal_id, merge_strategy="weave" ) assert updated is not None assert updated.merge_strategy == "weave" @pytest.mark.asyncio async def test_update_none_strategy_preserves_existing(self, db_session: AsyncSession) -> None: from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch, _make_proposal from musehub.services.musehub_proposals import update_proposal repo_id = await _make_repo(db_session) await _make_branch(db_session, repo_id, "feat/b", {}) proposal_id = await _make_proposal(db_session, repo_id, from_branch="feat/b", merge_strategy="replay") updated = await update_proposal( db_session, repo_id, proposal_id, merge_strategy=None, title="new title" ) assert updated is not None assert updated.merge_strategy == "replay" @pytest.mark.asyncio async def test_update_strategy_never_writes_null(self, db_session: AsyncSession) -> None: from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch, _make_proposal from musehub.services import musehub_proposals from musehub.db.musehub_social_models import MusehubProposal repo_id = await _make_repo(db_session) await _make_branch(db_session, repo_id, "feat/c", {}) proposal_id = await _make_proposal(db_session, repo_id, from_branch="feat/c") await musehub_proposals.update_proposal( db_session, repo_id, proposal_id, merge_strategy=None, title="updated" ) row = (await db_session.execute( sa.select(MusehubProposal).where( MusehubProposal.proposal_id == proposal_id ) )).scalar_one() assert row.merge_strategy is not None assert row.merge_strategy == "overlay" # --------------------------------------------------------------------------- # Migration 0050 — backfill NULLs # --------------------------------------------------------------------------- class TestMigration0070CanonicalNames: def test_migration_file_exists(self) -> None: path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0070_backfill_merge_strategy_canonical.py" assert path.exists(), "Migration 0070_backfill_merge_strategy_canonical.py must exist" def test_migration_has_correct_revision(self) -> None: path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0070_backfill_merge_strategy_canonical.py" content = path.read_text() assert 'revision = "0070"' in content assert 'down_revision = "0069"' in content def test_migration_renames_all_four_aliases(self) -> None: """Migration upgrade() must UPDATE all four legacy alias values.""" path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0070_backfill_merge_strategy_canonical.py" content = path.read_text() for old, new in [("state_overlay", "overlay"), ("state_weave", "weave"), ("state_rebase", "replay"), ("domain_selective", "selective")]: assert old in content, f"Migration must handle {old!r}" assert new in content, f"Migration must set {new!r}" def test_migration_importable(self) -> None: spec = importlib.util.spec_from_file_location( "migration_0070", pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0070_backfill_merge_strategy_canonical.py", ) assert spec is not None mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) # type: ignore[union-attr] assert hasattr(mod, "upgrade") assert hasattr(mod, "downgrade") # --------------------------------------------------------------------------- # Template — strategy always shown # --------------------------------------------------------------------------- class TestStrategyShownPostMerge: def _template_source(self) -> str: path = pathlib.Path(__file__).parent.parent / "musehub" / "templates" / "musehub" / "pages" / "proposal_detail.html" return path.read_text() def test_no_legacy_alias_in_template(self) -> None: source = self._template_source() for alias in ("state_overlay", "state_weave", "state_rebase", "domain_selective"): assert alias not in source, f"Legacy alias {alias!r} must not appear in template" def test_strategy_gated_on_merged_state(self) -> None: source = self._template_source() # Strategy display should be conditional on merged state assert "state == 'merged'" in source def test_strategy_row_references_merge_strategy(self) -> None: source = self._template_source() assert "proposal.merge_strategy" in source def _macro_source(self) -> str: path = pathlib.Path(__file__).parent.parent / "musehub" / "templates" / "musehub" / "fragments" / "proposal_rows.html" return path.read_text() def test_strategy_label_macro_covers_overlay(self) -> None: source = self._macro_source() assert "overlay" in source, "strategy_label macro must handle 'overlay'" def test_strategy_label_macro_covers_cherry_pick(self) -> None: source = self._macro_source() assert "cherry_pick" in source, "strategy_label macro must handle cherry_pick" def test_strategy_label_macro_has_fallback(self) -> None: source = self._macro_source() assert "else" in source, "strategy_label macro must have an else fallback so nothing renders blank" def test_no_legacy_aliases_in_macro(self) -> None: source = self._macro_source() for alias in ("state_overlay", "state_weave", "state_rebase", "domain_selective"): assert alias not in source, f"Legacy alias {alias!r} must not appear in macro"