"""A lesson is a row the database actually accepts (milestone 385 step 2). WHY THIS IS THE REAL GUARD, AND WHY IT IS HERE The step asked for "a guard that the CHECK actually accepts `lesson` and rejects a typo", citing #3128 — a `spike` kind that could not be written on an instance predating its migration. That failure mode belongs to `task_kind`, which IS gated (`notes_task_kind_check`, migrations 0056 and 0065). `note_type` is not gated at all: migration 0036 added it as plain Text with a server default and nothing has constrained it since. So there is no whitelist to expand and no rejection to assert. Writing the guard as "the constraint admits lesson" would have pinned a constraint that does not exist; writing it as "no constraint exists" would pin today's schema rather than the behaviour that matters, and would go red on a change that is perfectly fine. Asserting the WRITE covers both worlds. It passes today, it keeps passing if a CHECK is added that admits `lesson`, and it goes red the day one is added that does not — which is the only outcome anyone needs to be told about. The second test is the cell #3163 asks to be left empty on purpose: a lesson must not be a task. `is_task` is a read-only property over `status`, so this cannot be asserted by setting a flag — it has to be observed on a real row. """ import pytest import pytest_asyncio from sqlalchemy import select from scribe.models import async_session from scribe.models.note import Note from scribe.services import lessons as lessons_svc from scribe.services import notes as notes_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] OWNER_USERNAME = "lesson_kind_owner" TRIGGER = "the operator pasted a stack trace and said it is still broken" SUBJECT = "Change one thing, then look" @pytest_asyncio.fixture async def owner_id(): async with async_session() as s: owner = await ensure_user(s, OWNER_USERNAME) await s.commit() uid = owner.id # Cleaned at SETUP rather than teardown: create_note fires a detached # embedding refresh that opens its own connection and writes the row, # and a teardown delete would race it. A fresh loop has already # cancelled whatever the previous test left in flight. for note in (await s.execute( select(Note).where( Note.user_id == uid, Note.note_type == lessons_svc.LESSON_NOTE_TYPE, ) )).scalars().all(): await s.delete(note) await s.commit() return uid async def test_a_lesson_is_a_row_the_database_accepts(owner_id): """The whole point. Goes red if `note_type` is ever gated without this value, and stays correct if it is gated with it.""" lesson = await notes_svc.create_note( owner_id, title=lessons_svc.compose_title(SUBJECT, TRIGGER), body=f"**When to apply:** {TRIGGER}\n\nOne change at a time.", note_type=lessons_svc.LESSON_NOTE_TYPE, data={lessons_svc.TRIGGER_KEY: TRIGGER}, ) async with async_session() as s: stored = (await s.execute( select(Note).where(Note.id == lesson.id) )).scalars().one() assert stored.note_type == "lesson" # The trigger survives the round trip on both halves — the indexed mirror # and the readable body — because the vector is built from the text and # the queries are built from the mirror. assert lessons_svc.lesson_trigger(stored) == TRIGGER assert stored.title == f"{SUBJECT} — {TRIGGER}" assert "**When to apply:**" in (stored.body or "") async def test_a_lesson_is_not_a_task(owner_id): """#3163's cell left empty on purpose. `is_task` is `status is not None` and is read-only, so this is only observable on a stored row. A lesson that arrived with a status would join the open-work listings — the one way an unfilled field changes what the record IS rather than what it says. """ lesson = await notes_svc.create_note( owner_id, title=lessons_svc.compose_title(SUBJECT, TRIGGER), body="One change at a time.", note_type=lessons_svc.LESSON_NOTE_TYPE, ) async with async_session() as s: stored = (await s.execute( select(Note).where(Note.id == lesson.id) )).scalars().one() assert stored.status is None assert stored.is_task is False assert stored.milestone_id is None