gabriel / musehub public
test_merge_strategy_integrity.py python
226 lines 9.8 KB
Raw
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 100 days ago
1 """TDD — merge_strategy integrity: never NULL, always shown.
2
3 Covers:
4 Service — create_proposal never stores NULL merge_strategy
5 Service — update_proposal never writes NULL merge_strategy
6 Migration — migration 0050 backfills NULL rows to 'state_overlay'
7 Template — strategy row shown for state_overlay and all other values
8 """
9
10 from __future__ import annotations
11
12 import datetime
13 import importlib
14 import pathlib
15 import sys
16 import types
17
18 import pytest
19 import sqlalchemy as sa
20 from sqlalchemy.ext.asyncio import AsyncSession
21
22 from musehub.models.musehub import MergeStrategy
23
24 # ---------------------------------------------------------------------------
25 # Service layer — create_proposal
26 # ---------------------------------------------------------------------------
27
28
29 class TestCreateProposalMergeStrategyDefault:
30
31 @pytest.mark.asyncio
32 async def test_create_defaults_to_state_overlay(self, db_session: AsyncSession) -> None:
33 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch
34 from musehub.services.musehub_proposals import create_proposal
35
36 repo_id = await _make_repo(db_session)
37 await _make_branch(db_session, repo_id, "feat/x", {})
38 result = await create_proposal(
39 db_session,
40 repo_id=repo_id,
41 title="No strategy given",
42 from_branch="feat/x",
43 to_branch="main",
44 )
45 assert result.merge_strategy == "state_overlay"
46
47 @pytest.mark.asyncio
48 async def test_create_explicit_strategy_stored(self, db_session: AsyncSession) -> None:
49 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch
50 from musehub.services.musehub_proposals import create_proposal
51
52 repo_id = await _make_repo(db_session)
53 await _make_branch(db_session, repo_id, "feat/y", {})
54 result = await create_proposal(
55 db_session,
56 repo_id=repo_id,
57 title="Explicit strategy",
58 from_branch="feat/y",
59 to_branch="main",
60 merge_strategy="state_rebase",
61 )
62 assert result.merge_strategy == "state_rebase"
63
64 @pytest.mark.asyncio
65 async def test_create_cherry_pick_strategy_stored(self, db_session: AsyncSession) -> None:
66 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch
67 from musehub.services.musehub_proposals import create_proposal
68
69 repo_id = await _make_repo(db_session)
70 await _make_branch(db_session, repo_id, "feat/z", {})
71 result = await create_proposal(
72 db_session,
73 repo_id=repo_id,
74 title="Cherry pick proposal",
75 from_branch="feat/z",
76 to_branch="main",
77 merge_strategy="cherry_pick",
78 )
79 assert result.merge_strategy == "cherry_pick"
80
81
82 # ---------------------------------------------------------------------------
83 # Service layer — update_proposal
84 # ---------------------------------------------------------------------------
85
86
87 class TestUpdateProposalMergeStrategyIntegrity:
88
89 @pytest.mark.asyncio
90 async def test_update_strategy_changes_value(self, db_session: AsyncSession) -> None:
91 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch, _make_proposal
92 from musehub.services.musehub_proposals import update_proposal
93
94 repo_id = await _make_repo(db_session)
95 await _make_branch(db_session, repo_id, "feat/a", {})
96 proposal_id = await _make_proposal(db_session, repo_id, from_branch="feat/a")
97 updated = await update_proposal(
98 db_session, repo_id, proposal_id, merge_strategy="state_weave"
99 )
100 assert updated is not None
101 assert updated.merge_strategy == "state_weave"
102
103 @pytest.mark.asyncio
104 async def test_update_none_strategy_preserves_existing(self, db_session: AsyncSession) -> None:
105 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch, _make_proposal
106 from musehub.services.musehub_proposals import update_proposal
107
108 repo_id = await _make_repo(db_session)
109 await _make_branch(db_session, repo_id, "feat/b", {})
110 proposal_id = await _make_proposal(db_session, repo_id, from_branch="feat/b", merge_strategy="state_rebase")
111 updated = await update_proposal(
112 db_session, repo_id, proposal_id, merge_strategy=None, title="new title"
113 )
114 assert updated is not None
115 assert updated.merge_strategy == "state_rebase"
116
117 @pytest.mark.asyncio
118 async def test_update_strategy_never_writes_null(self, db_session: AsyncSession) -> None:
119 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch, _make_proposal
120 from musehub.services import musehub_proposals
121 from musehub.db.musehub_social_models import MusehubProposal
122
123 repo_id = await _make_repo(db_session)
124 await _make_branch(db_session, repo_id, "feat/c", {})
125 proposal_id = await _make_proposal(db_session, repo_id, from_branch="feat/c")
126
127 await musehub_proposals.update_proposal(
128 db_session, repo_id, proposal_id, merge_strategy=None, title="updated"
129 )
130
131 row = (await db_session.execute(
132 sa.select(MusehubProposal).where(
133 MusehubProposal.proposal_id == proposal_id
134 )
135 )).scalar_one()
136 assert row.merge_strategy is not None
137 assert row.merge_strategy == "state_overlay"
138
139
140 # ---------------------------------------------------------------------------
141 # Migration 0050 — backfill NULLs
142 # ---------------------------------------------------------------------------
143
144
145 class TestMigration0050Backfill:
146
147 def test_migration_file_exists(self) -> None:
148 path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0050_backfill_merge_strategy.py"
149 assert path.exists(), "Migration 0050_backfill_merge_strategy.py must exist"
150
151 def test_migration_has_correct_revision(self) -> None:
152 path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0050_backfill_merge_strategy.py"
153 content = path.read_text()
154 assert 'revision: str = "0050"' in content
155 assert 'down_revision: str = "0049"' in content
156
157 def test_migration_upgrades_null_rows(self) -> None:
158 """Migration upgrade() must UPDATE NULL merge_strategy rows to state_overlay."""
159 path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0050_backfill_merge_strategy.py"
160 content = path.read_text()
161 assert "state_overlay" in content
162 assert "merge_strategy" in content
163 assert "IS NULL" in content or "is_(None)" in content or "NULL" in content
164
165 def test_migration_importable(self) -> None:
166 spec = importlib.util.spec_from_file_location(
167 "migration_0050",
168 pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0050_backfill_merge_strategy.py",
169 )
170 assert spec is not None
171 mod = importlib.util.module_from_spec(spec)
172 spec.loader.exec_module(mod) # type: ignore[union-attr]
173 assert hasattr(mod, "upgrade")
174 assert hasattr(mod, "downgrade")
175
176
177 # ---------------------------------------------------------------------------
178 # Template — strategy always shown
179 # ---------------------------------------------------------------------------
180
181
182 class TestStrategyAlwaysShown:
183
184 def _template_source(self) -> str:
185 path = pathlib.Path(__file__).parent.parent / "musehub" / "templates" / "musehub" / "pages" / "proposal_detail.html"
186 return path.read_text()
187
188 def test_strategy_row_not_hidden_for_state_overlay(self) -> None:
189 source = self._template_source()
190 # The old condition hid state_overlay — it must be gone
191 assert "!= 'state_overlay'" not in source
192 assert '!= "state_overlay"' not in source
193
194 def test_strategy_row_shown_when_strategy_present(self) -> None:
195 source = self._template_source()
196 # The strategy row should be gated only on presence, not value
197 # Find the strategy block and confirm it shows for any non-null strategy
198 assert "proposal.merge_strategy" in source
199
200 def _macro_source(self) -> str:
201 path = pathlib.Path(__file__).parent.parent / "musehub" / "templates" / "musehub" / "fragments" / "proposal_rows.html"
202 return path.read_text()
203
204 def test_strategy_label_macro_covers_state_overlay(self) -> None:
205 source = self._macro_source()
206 assert "state_overlay" in source, "strategy_label macro must handle state_overlay"
207
208 def test_strategy_label_macro_covers_cherry_pick(self) -> None:
209 source = self._macro_source()
210 assert "cherry_pick" in source, "strategy_label macro must handle cherry_pick"
211
212 def test_strategy_label_macro_has_fallback(self) -> None:
213 source = self._macro_source()
214 assert "else" in source, "strategy_label macro must have an else fallback so nothing renders blank"
215
216 def test_none_strategy_falls_back_to_state_overlay_label(self) -> None:
217 source = self._template_source()
218 # Template must handle None — either via 'or' default or a separate guard
219 # Accept either pattern: `proposal.merge_strategy or 'state_overlay'`
220 # or `{% if proposal.merge_strategy %} ... {% else %}state_overlay{% endif %}`
221 has_or_default = ("merge_strategy or 'state_overlay'" in source or
222 'merge_strategy or "state_overlay"' in source)
223 has_else_default = ("else" in source and "state_overlay" in source)
224 assert has_or_default or has_else_default, (
225 "Template must render 'state_overlay' when merge_strategy is None"
226 )
File History 1 commit
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 100 days ago