"""The real-Postgres lane (family rule 6). Everything else in this suite is deliberately DB-free, which means the schema the migrations build has never been checked against the models that read it. That gap is what this file closes, and it is not theoretical: M13 dropped three columns and rebuilt a generated column, and until now `alembic upgrade head` ran for the first time when the operator's container started. Marked `integration` and excluded from the unit lane by `-m "not integration"`, so a workstation without Postgres runs the rest of the suite unchanged. The schema comes from real migrations, never `metadata.create_all` (rule 82) — the point is to test what actually ships, and `create_all` would build a schema no deployment has ever seen. """ from __future__ import annotations import uuid import pytest import pytest_asyncio from sqlalchemy import select, text from thoughtsync.db import dispose_engine, session_scope from thoughtsync.models.note import Note from thoughtsync.models.note_item import NoteItem from thoughtsync.models.user import User from thoughtsync.notes.helpers import derive_display_title from thoughtsync.sync import _apply_note_items pytestmark = pytest.mark.integration # Every table the tests touch, child-first so FKs never block the truncate. # RESTART IDENTITY + CASCADE keeps this honest if a table gains children later. _TABLES = "notes, note_items, note_revisions, note_labels, labels, users" @pytest_asyncio.fixture async def db(): """A session against the migrated database, wiped before each test. Wiped BEFORE rather than after so a failed test leaves its rows behind to look at. """ async with session_scope() as session: await session.execute(text(f"TRUNCATE {_TABLES} RESTART IDENTITY CASCADE")) await session.commit() yield session await dispose_engine() @pytest_asyncio.fixture async def owner(db): """A user to hang notes off — `notes.owner_id` is a real foreign key.""" user = User(email=f"{uuid.uuid4().hex}@example.test", display_name="Integration") db.add(user) await db.commit() await db.refresh(user) return user async def test_the_migrated_schema_matches_the_models(db, owner): """The check that has never run: insert through the ORM, read it back. A column the models expect and the migrations never created — or the reverse — fails right here, instead of when a container starts. """ note = Note(owner_id=owner.id, body="a thought", display_title="a thought") db.add(note) await db.commit() await db.refresh(note) found = await db.scalar(select(Note).where(Note.id == note.id)) assert found is not None assert found.body == "a thought" assert found.display_title == "a thought" async def test_the_dropped_columns_are_actually_gone(db): """M13 dropped three. If a migration silently no-opped, this is where it shows.""" cols = set( ( await db.execute( text("SELECT column_name FROM information_schema.columns WHERE table_name = 'notes'") ) ) .scalars() .all() ) assert "title" not in cols, "notes.title should have gone in 0026" assert "kind" not in cols, "notes.kind should have gone in 0025" assert "display_title" in cols and "body" in cols rev_cols = set( ( await db.execute( text("SELECT column_name FROM information_schema.columns WHERE table_name = 'note_revisions'") ) ) .scalars() .all() ) assert "title" not in rev_cols, "note_revisions.title should have gone in 0026" tables = set( (await db.execute(text("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"))) .scalars() .all() ) assert "note_links" not in tables, "note_links should have gone in 0024" async def test_the_search_vector_was_rebuilt_over_the_name(db, owner): """0026 had to drop and recreate a STORED GENERATED column. Postgres refuses to drop a column another generated column depends on, so getting this wrong doesn't produce a subtly wrong ranking — it produces a migration that won't run at all. Worth proving the replacement actually indexes something. """ note = Note(owner_id=owner.id, body="ferry tickets\nbook before friday", display_title="ferry tickets") db.add(note) await db.commit() hit = await db.scalar( text( "SELECT count(*) FROM notes " "WHERE search_vector @@ websearch_to_tsquery('english', :q)" ).bindparams(q="ferry") ) assert hit == 1 # The NAME is weight A and the body weight B, which is what makes a name match # rank above a body-only one. Both must be in the vector at all. body_only = await db.scalar( text( "SELECT count(*) FROM notes " "WHERE search_vector @@ websearch_to_tsquery('english', :q)" ).bindparams(q="friday") ) assert body_only == 1 async def test_a_note_keeps_both_its_body_and_its_items(db, owner): """The shape M13 step 2 made normal: a note HAS a checklist, it isn't one.""" note = Note(owner_id=owner.id, body="weekend shop", display_title="weekend shop") db.add(note) await db.flush() db.add_all( [ NoteItem(note_id=note.id, text="milk", position=0), NoteItem(note_id=note.id, text="eggs", position=1), ] ) await db.commit() items = ( await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id).order_by(NoteItem.position)) ).all() assert [i.text for i in items] == ["milk", "eggs"] assert (await db.scalar(select(Note.body).where(Note.id == note.id))) == "weekend shop" async def test_sync_no_longer_deletes_items_from_a_note_with_a_body(db, owner): """The data-loss path step 2 removed, pinned against a real database. `_apply_note_items` used to delete every item when the note wasn't `kind = "list"`. Nothing can produce that state any more, but this is the regression that would have silently eaten a checklist, and it deserves a test that would catch its return. """ note = Note(owner_id=owner.id, body="packing", display_title="packing") db.add(note) await db.flush() db.add(NoteItem(note_id=note.id, text="socks", position=0)) await db.commit() # A change that says nothing about items must LEAVE them alone — absent means # "not telling us", not "empty". await _apply_note_items(db, note, {"body": "packing"}) await db.commit() assert (await db.scalar(select(NoteItem.text).where(NoteItem.note_id == note.id))) == "socks" # An explicit list replaces them. await _apply_note_items(db, note, {"items": [{"text": "charger", "checked": True}]}) await db.commit() rows = (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all() assert [(r.text, r.checked) for r in rows] == [("charger", True)] async def test_a_note_with_only_items_still_has_a_name(db, owner): """The hole that made removing the title unsafe until step 2 closed it.""" note = Note(owner_id=owner.id, body="", display_title="") db.add(note) await db.flush() db.add(NoteItem(note_id=note.id, text="milk", position=0)) await db.commit() first = await db.scalar( select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1) ) note.display_title = derive_display_title(note.body, first) await db.commit() assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk"