"""A lesson is a row the database actually accepts. WHY THIS IS THE REAL GUARD, AND WHY IT IS HERE The obvious ask is a guard that a CHECK accepts `lesson` and rejects a typo — the failure where a new kind cannot 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 pin 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 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 grimoire.models import async_session from grimoire.models.note import Note from grimoire.services import lessons as lessons_svc from grimoire.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=SUBJECT, 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 # The subject alone: the trigger is in `data` and the body. assert stored.title == SUBJECT assert "**When to apply:**" in (stored.body or "") async def test_a_lesson_is_not_a_task(owner_id): """The 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=SUBJECT, 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