Files
FabledGrimoire/tests/test_integration_lesson_kind.py
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

114 lines
4.3 KiB
Python

"""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