gabriel / musehub public
test_data_integrity.py python
313 lines 10.1 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Tests for checklist section 5.1 — Database integrity.
2
3 Covers:
4 - updated_at present on critical tables
5 - FK constraints enforced (PostgreSQL)
6 - Orphan object scan (scan and delete)
7 """
8 from __future__ import annotations
9
10 import pytest
11 from muse.core.types import fake_id
12 from sqlalchemy.ext.asyncio import AsyncSession
13
14 from tests.factories import create_repo
15
16
17 # ── updated_at on critical tables ─────────────────────────────────────────────
18
19 def test_musehub_repo_has_updated_at() -> None:
20 from musehub.db.musehub_models import MusehubRepo
21 cols = {c.name for c in MusehubRepo.__table__.columns}
22 assert "updated_at" in cols, "MusehubRepo is missing updated_at column"
23
24
25 def test_musehub_proposal_has_updated_at() -> None:
26 from musehub.db.musehub_models import MusehubProposal
27 cols = {c.name for c in MusehubProposal.__table__.columns}
28 assert "updated_at" in cols, "MusehubProposal is missing updated_at column"
29
30
31 def test_musehub_webhook_has_updated_at() -> None:
32 from musehub.db.musehub_models import MusehubWebhook
33 cols = {c.name for c in MusehubWebhook.__table__.columns}
34 assert "updated_at" in cols, "MusehubWebhook is missing updated_at column"
35
36
37 def test_musehub_release_has_updated_at() -> None:
38 from musehub.db.musehub_models import MusehubRelease
39 cols = {c.name for c in MusehubRelease.__table__.columns}
40 assert "updated_at" in cols, "MusehubRelease is missing updated_at column"
41
42
43 def test_existing_critical_tables_have_updated_at() -> None:
44 """Spot-check that pre-existing updated_at columns are still present."""
45 from musehub.db.musehub_models import (
46 MusehubIssue,
47 MusehubIssueComment,
48 )
49 for model in (MusehubIssue, MusehubIssueComment):
50 cols = {c.name for c in model.__table__.columns}
51 assert "updated_at" in cols, f"{model.__name__} is missing updated_at"
52
53
54 async def test_repo_updated_at_is_populated_on_create(
55 db_session: AsyncSession,
56 ) -> None:
57 """A freshly created repo must have a non-null updated_at."""
58 repo = await create_repo(db_session, slug="updated-at-test", owner="testuser")
59 assert repo.updated_at is not None, "updated_at must be set on repo creation"
60
61
62 # ── Orphan object scan ─────────────────────────────────────────────────────────
63
64 async def test_orphan_scan_returns_empty_when_no_orphans(
65 db_session: AsyncSession,
66 ) -> None:
67 """scan_orphan_objects must return empty when all objects have at least one ref."""
68 from musehub.maintenance.orphan_scan import scan_orphan_objects
69 from musehub.db import musehub_models as db_models
70
71 repo = await create_repo(db_session, slug="orphan-scan-clean", owner="testuser")
72
73 oid = fake_id("clean-object")
74 obj = db_models.MusehubObject(
75 object_id=oid,
76 path="test.bin",
77 size_bytes=4,
78 disk_path="test.bin",
79 storage_uri="local://test.bin",
80 )
81 db_session.add(obj)
82 db_session.add(db_models.MusehubObjectRef(repo_id=repo.repo_id, object_id=oid))
83 await db_session.commit()
84
85 result = await scan_orphan_objects(db_session)
86 assert result.ok
87 assert result.count == 0
88
89
90 async def test_orphan_scan_detects_objects_with_no_refs(
91 db_session: AsyncSession,
92 ) -> None:
93 """scan_orphan_objects must find objects that have no row in musehub_object_refs."""
94 from musehub.maintenance.orphan_scan import scan_orphan_objects
95 from musehub.db import musehub_models as db_models
96
97 orphan_obj_id = fake_id("orphan-object")
98
99 # Insert an object with no corresponding ref row.
100 obj = db_models.MusehubObject(
101 object_id=orphan_obj_id,
102 path="orphan.bin",
103 size_bytes=4,
104 disk_path="orphan.bin",
105 storage_uri="local://orphan.bin",
106 )
107 db_session.add(obj)
108 await db_session.commit()
109
110 result = await scan_orphan_objects(db_session)
111 assert not result.ok, "Orphan scan should detect the unreferenced object"
112 assert orphan_obj_id in result.orphaned_object_ids
113
114
115 # ── ingest_push parent validation (Phase 8 / invariant 8 parity) ──────────────
116
117
118 async def test_ingest_push_rejects_missing_external_parent(
119 db_session: AsyncSession,
120 ) -> None:
121 """ingest_push() must raise ValueError when a parent commit is not in DB.
122
123 A client pushing a commit that references a fabricated or missing parent_id
124 must be rejected. Without this guard, history becomes unreadable because
125 muse log walks off the end when it tries to fetch the non-existent parent.
126 """
127 from musehub.models.musehub import CommitInput, ObjectInput
128 from musehub.services.musehub_sync import ingest_push
129 from tests.factories import create_repo
130 from muse.core.types import blob_id
131
132 repo = await create_repo(db_session, slug="parent-val-test", owner="testuser")
133 repo_id = str(repo.repo_id)
134
135 ghost_parent_id = blob_id(b"nonexistent parent")
136 commit_id = blob_id(b"orphan commit")
137
138 commits = [
139 CommitInput(
140 commit_id=commit_id,
141 branch="main",
142 parent_ids=[ghost_parent_id],
143 message="commit with bogus parent",
144 author="tester",
145 timestamp="2026-01-01T00:00:00Z",
146 snapshot_id=None,
147 )
148 ]
149
150 with pytest.raises(ValueError, match="missing_parent_commits"):
151 await ingest_push(
152 db_session,
153 repo_id=repo_id,
154 branch="main",
155 head_commit_id=commit_id,
156 commits=commits,
157 snapshots=[],
158 objects=[],
159 force=False,
160 author="tester",
161 )
162
163
164 async def test_ingest_push_accepts_parent_in_same_bundle(
165 db_session: AsyncSession,
166 ) -> None:
167 """ingest_push() must accept a commit whose parent is in the same push bundle."""
168 from musehub.models.musehub import CommitInput, ObjectInput
169 from musehub.services.musehub_sync import ingest_push
170 from tests.factories import create_repo
171 from muse.core.types import blob_id
172
173 repo = await create_repo(db_session, slug="bundle-parent-test", owner="testuser")
174 repo_id = str(repo.repo_id)
175
176 first_id = blob_id(b"first commit")
177 second_id = blob_id(b"second commit")
178
179 commits = [
180 CommitInput(
181 commit_id=first_id,
182 branch="main",
183 parent_ids=[],
184 message="genesis",
185 author="tester",
186 timestamp="2026-01-01T00:00:00Z",
187 snapshot_id=None,
188 ),
189 CommitInput(
190 commit_id=second_id,
191 branch="main",
192 parent_ids=[first_id],
193 message="second",
194 author="tester",
195 timestamp="2026-01-01T00:01:00Z",
196 snapshot_id=None,
197 ),
198 ]
199
200 result = await ingest_push(
201 db_session,
202 repo_id=repo_id,
203 branch="main",
204 head_commit_id=second_id,
205 commits=commits,
206 snapshots=[],
207 objects=[],
208 force=False,
209 author="tester",
210 )
211 assert result.ok
212 assert result.remote_head == second_id
213
214
215 async def test_ingest_push_accepts_parent_already_in_db(
216 db_session: AsyncSession,
217 ) -> None:
218 """ingest_push() must accept a commit whose parent is already stored in the DB."""
219 from musehub.models.musehub import CommitInput
220 from musehub.services.musehub_sync import ingest_push
221 from tests.factories import create_repo
222 from muse.core.types import blob_id
223
224 repo = await create_repo(db_session, slug="db-parent-test", owner="testuser")
225 repo_id = str(repo.repo_id)
226
227 first_id = blob_id(b"db-stored first commit")
228
229 # Push the first commit to establish it in DB.
230 await ingest_push(
231 db_session,
232 repo_id=repo_id,
233 branch="main",
234 head_commit_id=first_id,
235 commits=[
236 CommitInput(
237 commit_id=first_id,
238 branch="main",
239 parent_ids=[],
240 message="genesis",
241 author="tester",
242 timestamp="2026-01-01T00:00:00Z",
243 snapshot_id=None,
244 )
245 ],
246 snapshots=[],
247 objects=[],
248 force=False,
249 author="tester",
250 )
251
252 # Now push a second commit that references the DB-stored first commit.
253 second_id = blob_id(b"db-stored second commit")
254 result = await ingest_push(
255 db_session,
256 repo_id=repo_id,
257 branch="main",
258 head_commit_id=second_id,
259 commits=[
260 CommitInput(
261 commit_id=second_id,
262 branch="main",
263 parent_ids=[first_id],
264 message="incremental push",
265 author="tester",
266 timestamp="2026-01-01T00:01:00Z",
267 snapshot_id=None,
268 )
269 ],
270 snapshots=[],
271 objects=[],
272 force=False,
273 author="tester",
274 )
275 assert result.ok
276 assert result.remote_head == second_id
277
278
279 async def test_ingest_push_genesis_commit_no_parent_accepted(
280 db_session: AsyncSession,
281 ) -> None:
282 """ingest_push() must accept a genesis commit with an empty parent_ids list."""
283 from musehub.models.musehub import CommitInput
284 from musehub.services.musehub_sync import ingest_push
285 from tests.factories import create_repo
286 from muse.core.types import blob_id
287
288 repo = await create_repo(db_session, slug="genesis-test", owner="testuser")
289 genesis_id = blob_id(b"genesis commit fresh")
290
291 result = await ingest_push(
292 db_session,
293 repo_id=str(repo.repo_id),
294 branch="main",
295 head_commit_id=genesis_id,
296 commits=[
297 CommitInput(
298 commit_id=genesis_id,
299 branch="main",
300 parent_ids=[],
301 message="initial commit",
302 author="tester",
303 timestamp="2026-01-01T00:00:00Z",
304 snapshot_id=None,
305 )
306 ],
307 snapshots=[],
308 objects=[],
309 force=False,
310 author="tester",
311 )
312 assert result.ok
313 assert result.remote_head == genesis_id
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago