"""TDD — merge_strategy integrity: never NULL, always shown. Covers: Service — create_proposal never stores NULL merge_strategy Service — update_proposal never writes NULL merge_strategy Migration — migration 0050 backfills NULL rows to 'state_overlay' Template — strategy row shown for state_overlay and all other values """ 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_state_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 == "state_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="state_rebase", ) assert result.merge_strategy == "state_rebase" @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="state_weave" ) assert updated is not None assert updated.merge_strategy == "state_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="state_rebase") 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 == "state_rebase" @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 == "state_overlay" # --------------------------------------------------------------------------- # Migration 0050 — backfill NULLs # --------------------------------------------------------------------------- class TestMigration0050Backfill: def test_migration_file_exists(self) -> None: path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0050_backfill_merge_strategy.py" assert path.exists(), "Migration 0050_backfill_merge_strategy.py must exist" def test_migration_has_correct_revision(self) -> None: path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0050_backfill_merge_strategy.py" content = path.read_text() assert 'revision: str = "0050"' in content assert 'down_revision: str = "0049"' in content def test_migration_upgrades_null_rows(self) -> None: """Migration upgrade() must UPDATE NULL merge_strategy rows to state_overlay.""" path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0050_backfill_merge_strategy.py" content = path.read_text() assert "state_overlay" in content assert "merge_strategy" in content assert "IS NULL" in content or "is_(None)" in content or "NULL" in content def test_migration_importable(self) -> None: spec = importlib.util.spec_from_file_location( "migration_0050", pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0050_backfill_merge_strategy.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 TestStrategyAlwaysShown: def _template_source(self) -> str: path = pathlib.Path(__file__).parent.parent / "musehub" / "templates" / "musehub" / "pages" / "proposal_detail.html" return path.read_text() def test_strategy_row_not_hidden_for_state_overlay(self) -> None: source = self._template_source() # The old condition hid state_overlay — it must be gone assert "!= 'state_overlay'" not in source assert '!= "state_overlay"' not in source def test_strategy_row_shown_when_strategy_present(self) -> None: source = self._template_source() # The strategy row should be gated only on presence, not value # Find the strategy block and confirm it shows for any non-null strategy 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_state_overlay(self) -> None: source = self._macro_source() assert "state_overlay" in source, "strategy_label macro must handle state_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_none_strategy_falls_back_to_state_overlay_label(self) -> None: source = self._template_source() # Template must handle None — either via 'or' default or a separate guard # Accept either pattern: `proposal.merge_strategy or 'state_overlay'` # or `{% if proposal.merge_strategy %} ... {% else %}state_overlay{% endif %}` has_or_default = ("merge_strategy or 'state_overlay'" in source or 'merge_strategy or "state_overlay"' in source) has_else_default = ("else" in source and "state_overlay" in source) assert has_or_default or has_else_default, ( "Template must render 'state_overlay' when merge_strategy is None" )