"""TDD — musehub_commits.created_at must never be NULL after a push. Root cause (2026-05-07) ----------------------- wire_push_stream builds new_commit_rows as plain dicts and inserts them via pg_insert(MusehubCommit).values(new_commit_rows). SQLAlchemy includes ALL ORM model columns in the INSERT when using .values() with a list of dicts — columns absent from a dict are bound as NULL, overriding any column DEFAULT or server_default. musehub_commits.created_at is NOT NULL with DEFAULT now(). Migration 0029 adds that DEFAULT at the DB level, but an explicit NULL in the INSERT still violates the NOT NULL constraint. Fix --- Add "created_at": _utc_now() to the new_commit_rows dict so the value is always an explicit datetime, never NULL. Tests ----- T1 Regression guard — pg_insert with explicit NULL for created_at raises IntegrityError. Documents the DB constraint is enforced; always passes. T2 Fix driver — a full HTTP push via POST /{owner}/{slug}/push/stream produces a musehub_commits row with non-null created_at. RED before fix (wire_push_stream omits created_at → NULL → IntegrityError). GREEN after fix (created_at=_utc_now() added to new_commit_rows). T3 Upsert preservation — re-pushing the same commit ID does not overwrite the original created_at (created_at is absent from on_conflict_do_update set_, so the original row value is preserved). """ from __future__ import annotations import zlib from datetime import datetime, timezone import msgpack import pytest from httpx import AsyncClient from sqlalchemy import select, text from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from muse.core.mpack import MuseWireFrameWriter from muse.core.types import blob_id from musehub.db import musehub_models as db from musehub.models.wire import ( SFRAME_COMMIT_PACK, SFRAME_END, SFRAME_HEADER, SFRAME_OBJECT, ) from tests.factories import create_repo _fw = MuseWireFrameWriter() # --------------------------------------------------------------------------- # Helpers — identical to test_push_timeout_fix.py so each test file is # self-contained and the pattern is obvious. # --------------------------------------------------------------------------- def _utc_now() -> datetime: return datetime.now(tz=timezone.utc) def _oid(data: bytes) -> str: return blob_id(data) def _wrap(frame_type: str, payload) -> bytes: return _fw.wrap(frame_type=frame_type, payload=msgpack.packb(payload, use_bin_type=True)) def _header_frame(branch: str = "dev", n_objects: int = 0, n_commits: int = 1) -> bytes: return _wrap(SFRAME_HEADER, { "t": SFRAME_HEADER, "branch": branch, "force": False, "have": [], "head": _oid(b"head"), "n_objects": n_objects, "n_commits": n_commits, }) def _commit_pack_frame(commits, snapshots) -> bytes: return _wrap(SFRAME_COMMIT_PACK, { "t": SFRAME_COMMIT_PACK, "commits": commits, "snapshots": snapshots, }) def _end_frame(n_objects: int = 0, n_commits: int = 1) -> bytes: return _wrap(SFRAME_END, {"t": SFRAME_END, "n_objects": n_objects, "n_commits": n_commits}) def _make_wire_commit(snap_id: str, branch: str = "dev"): return { "commit_id": _oid(f"commit-{snap_id}".encode()), "parent_ids": [], "snapshot_id": snap_id, "branch": branch, "message": "fix: created_at tdd commit", "author": "test-user-wire", "committed_at": "2026-05-07T00:00:00+00:00", "signature": "", "signer_key_id": "", "agent_id": "claude-code", "model_id": "claude-sonnet-4-6", "metadata": {}, } def _make_snapshot(snap_id: str): return {"snapshot_id": snap_id, "manifest": {}} def _build_push_stream(snap_id: str, branch: str = "dev") -> bytes: commit = _make_wire_commit(snap_id, branch) snapshot = _make_snapshot(snap_id) return ( _header_frame(branch=branch, n_objects=0, n_commits=1) + _commit_pack_frame([commit], [snapshot]) + _end_frame(n_objects=0, n_commits=1) ) # --------------------------------------------------------------------------- # T1 — Regression guard: explicit NULL for created_at → IntegrityError # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_t1_explicit_null_created_at_raises_integrity_error( db_session: AsyncSession, ) -> None: """Direct SQL INSERT with NULL created_at raises a NOT NULL violation. This test always passes — it documents the DB constraint that prevents NULL values. The fix ensures the push handler never sends NULL here. """ repo = await create_repo(db_session, owner="test-user-wire", name="t1-null-guard") with pytest.raises(Exception) as exc_info: await db_session.execute(text( "INSERT INTO musehub_commits " "(commit_id, repo_id, branch, message, author, timestamp, parent_ids, created_at) " "VALUES (:cid, :rid, 'dev', 'test', 'test-user-wire', now(), '{}', NULL)" ), {"cid": _oid(b"t1-null-commit"), "rid": repo.repo_id}) await db_session.flush() assert "created_at" in str(exc_info.value).lower() or "not null" in str(exc_info.value).lower() # --------------------------------------------------------------------------- # T2 — Fix driver: HTTP push produces commit row with non-null created_at # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_t2_push_commit_has_non_null_created_at( client: AsyncClient, db_session: AsyncSession, wire_headers, ) -> None: """A full push via the wire protocol must produce a commit row where created_at IS NOT NULL. Before the fix: wire_push_stream builds new_commit_rows dicts without a 'created_at' key → SQLAlchemy inserts NULL → IntegrityError / 500. After the fix: new_commit_rows includes 'created_at': _utc_now() → INSERT succeeds → row exists with a real timestamp. """ repo = await create_repo( db_session, owner="test-user-wire", name="t2-created-at-fix", ) snap_id = _oid(b"snap-t2-created-at") payload = _build_push_stream(snap_id) resp = await client.post( f"/test-user-wire/{repo.slug}/push/stream", content=payload, headers=wire_headers, ) assert resp.status_code == 200, f"push failed: {resp.content[:500]}" expected_commit_id = _oid(f"commit-{snap_id}".encode()) result = await db_session.execute( select(db.MusehubCommit).where( db.MusehubCommit.commit_id == expected_commit_id, db.MusehubCommit.repo_id == repo.repo_id, ) ) row = result.scalar_one_or_none() assert row is not None, "commit row not found after push" assert row.created_at is not None, ( "created_at is NULL — wire_push_stream must add 'created_at': _utc_now() " "to new_commit_rows so SQLAlchemy doesn't bind NULL for the column" ) # --------------------------------------------------------------------------- # T3 — Upsert preservation: re-push does not overwrite original created_at # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_t3_upsert_preserves_original_created_at( client: AsyncClient, db_session: AsyncSession, wire_headers, ) -> None: """Re-pushing the same commit must not change the existing created_at. The on_conflict_do_update set_ dict deliberately excludes created_at so the original server-insertion timestamp is preserved across re-pushes. """ repo = await create_repo( db_session, owner="test-user-wire", name="t3-upsert-created-at", ) snap_id = _oid(b"snap-t3-upsert") payload = _build_push_stream(snap_id) # First push resp1 = await client.post( f"/test-user-wire/{repo.slug}/push/stream", content=payload, headers=wire_headers, ) assert resp1.status_code == 200, f"first push failed: {resp1.content[:500]}" commit_id = _oid(f"commit-{snap_id}".encode()) result1 = await db_session.execute( select(db.MusehubCommit).where( db.MusehubCommit.commit_id == commit_id, db.MusehubCommit.repo_id == repo.repo_id, ) ) row1 = result1.scalar_one() assert row1.created_at is not None original_created_at = row1.created_at # Second push (same commit — triggers on_conflict_do_update) resp2 = await client.post( f"/test-user-wire/{repo.slug}/push/stream", content=payload, headers=wire_headers, ) assert resp2.status_code == 200, f"second push failed: {resp2.content[:500]}" result2 = await db_session.execute( select(db.MusehubCommit) .where( db.MusehubCommit.commit_id == commit_id, db.MusehubCommit.repo_id == repo.repo_id, ) .execution_options(populate_existing=True) ) row2 = result2.scalar_one() assert row2.created_at == original_created_at, ( f"created_at changed on re-push: " f"{original_created_at} → {row2.created_at}\n" "created_at must be excluded from on_conflict_do_update set_ " "to preserve the original insertion timestamp." )