Files
LLeMbas/tests/test_migrations.py
Homer 3d51ba061e Tests that found things reading did not
The testing pass: 2140 tests to 2283, and four bugs that no amount of
reading had turned up. Three came from driving the JavaScript under a
Node DOM stub, which is the practice CLAUDE.md sets out and this is the
reason it does.

The terminal dropped every keystroke after a reconnect. `onclose` closed
over the module-level socket rather than its own, and close() queues its
event -- so the old socket's close arrived after a new one was assigned
and nulled the live one. Output kept coming, because onmessage is bound
to the object, while every send gates on the variable. It also announced
"Disconnected" about a shell that had just reconnected.

Two scripts were loaded twice on /messages, once by base.html and again
by the page. Each is an IIFE with its own state, so four keyboard
shortcuts toggled their panel twice and therefore did nothing, /help
opened two dialogs, and an @ mention attached its file twice. A sweep
refuses any template re-loading what base.html has.

The microphone had no guard while the permission prompt was up, so each
click opened another stream and only the last was ever stopped. And a
skill shared with you took its name out of your own library: create
checked uniqueness against what is *visible* rather than what is owned,
against a (owner_id, name) constraint, and told you to edit a row you
cannot edit.

--ink-faint failed the contrast minimum in both themes -- 3.85 and 3.19
against 4.5 -- so the smallest text on every screen was the hardest to
read. Measured in a headless browser rather than judged by eye.

And the suite runs on 3.11 and 3.12 now as well as 3.14. It had only ever
run on 3.14 while the image ships 3.12 and the packaging claimed 3.11:
the interpreter most people would run was the one nothing had tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:41:45 +02:00

233 lines
8.9 KiB
Python

"""The upgrade path, which every existing install takes to reach 1.0.0.
There is no Alembic here by design -- hard rule 4, additive-only, with
`db/migrations.py:sync_schema` deriving the change from the models by diffing
them against the live database. That makes the differ the *only* thing standing
between an 0.8.x deployment and a broken one, and until this file it had no test
that exercised it as a migration at all.
`tests/conftest.py` runs `create_all` and *then* `sync_schema`, so the schema it
is handed is always already current: the differ finds nothing missing, does
nothing, and reports success. Every run of the suite proved that a no-op is a
no-op. The two unit tests that did exist covered `_add_column_sql`'s DDL string
and never touched a database.
So these build an **old-shaped database with rows in it** and upgrade it for
real. The shape is not invented: `OLD_TABLES` and `OLD_COLUMNS` are what
actually arrived between `0.8.1` and this release, taken from
`git diff 00ce04a..HEAD -- src/lembas/db/models/`. `test_the_recorded_shape_is_still_real`
is what stops that list rotting into a test that upgrades nothing.
"""
from __future__ import annotations
import pytest
from sqlalchemy import inspect, text
from lembas.db.base import Base
from lembas.db.migrations import ensure_fts, sync_schema
from lembas.db.session import get_engine
# Tables that did not exist at 0.8.1. `sync_schema` has to create them.
OLD_TABLES = ("chunks", "push_subscriptions", "usage")
# Columns added to tables that already existed, and therefore already had rows.
# These are the interesting half: a new *table* is empty by definition, but a
# new *column* has to arrive beside data somebody cares about.
OLD_COLUMNS = (
("ssh_profiles", "resolves_here"),
("chats", "parent_chat_id"),
("chats", "unattended"),
("reports", "unread_notified"),
("groups", "limits_json"),
)
def _rollback_to_0_8_1(engine) -> None:
"""Take a current database back to the shape 0.8.1 left behind.
Backwards rather than forwards because the alternative is importing two
versions of the models into one interpreter, which is not a thing Python
will do -- `lembas.db.models` is already imported and registered on one
`Base.metadata`. Dropping what was added produces the same *shape* the
differ has to repair, which is what is under test.
"""
with engine.begin() as connection:
for table in OLD_TABLES:
connection.execute(text(f"DROP TABLE IF EXISTS {table}"))
for table, column in OLD_COLUMNS:
connection.execute(text(f"ALTER TABLE {table} DROP COLUMN {column}"))
def _columns(engine, table: str) -> set[str]:
return {column["name"] for column in inspect(engine).get_columns(table)}
def test_the_recorded_shape_is_still_real():
"""The rollback above names tables and columns by hand, and a name that has
since been removed or renamed would make it silently upgrade nothing --
a test that passes because it tested an empty set.
"""
tables = Base.metadata.tables
for table in OLD_TABLES:
assert table in tables, f"{table} is no longer a table; fix OLD_TABLES"
for table, column in OLD_COLUMNS:
assert table in tables, table
assert column in tables[table].c, f"{table}.{column} is gone; fix OLD_COLUMNS"
def test_an_0_8_1_database_with_data_upgrades(db):
"""The whole point. Rows written before the upgrade must still be there
afterwards, with their values, and the new columns must exist beside them.
A failure here is somebody's instance not starting after pressing Update,
or -- worse and quieter -- starting with a column that silently reads NULL.
"""
engine = get_engine()
# Data first, while the schema still has the columns the ORM expects.
from lembas.db.models import Chat, Group, User
owner = User(name="Frodo", email="f@shire.test", password_hash="x") # noqa: S106
db.add(owner)
db.commit()
group = Group(name="Fellowship")
db.add(group)
db.commit()
chat = Chat(user_id=owner.id, model_id="mithril", title="A chat from before")
db.add(chat)
db.commit()
chat_id, owner_id, group_id = chat.id, owner.id, group.id
db.close()
_rollback_to_0_8_1(engine)
# Confirm the rollback actually removed things, or the assertions below
# would pass against a database that was never old.
assert "chunks" not in inspect(engine).get_table_names()
assert "unattended" not in _columns(engine, "chats")
changes = sync_schema(engine)
assert changes, "the differ reported nothing to do on an old database"
for table in OLD_TABLES:
assert table in inspect(engine).get_table_names(), table
for table, column in OLD_COLUMNS:
assert column in _columns(engine, table), f"{table}.{column}"
# And the rows are still what they were.
with engine.connect() as connection:
row = connection.execute(
text("SELECT title, model_id, user_id FROM chats WHERE id = :id"),
{"id": chat_id},
).one()
assert row.title == "A chat from before"
assert row.model_id == "mithril"
assert row.user_id == owner_id
assert (
connection.execute(
text("SELECT name FROM groups WHERE id = :id"), {"id": group_id}
).scalar()
== "Fellowship"
)
def test_a_nullable_column_arrives_empty_rather_than_defaulted(db):
"""A nullable column is added with **no** default, so an existing row reads
NULL -- the value the model treats as absent.
An earlier version defaulted every column by type, which meant an added
foreign key arrived as `""` on old rows, and every "is this set?" check
downstream was wrong about them. `parent_chat_id` is exactly that shape: a
chat that predates subagents is not a helper, and `""` would not say so.
"""
engine = get_engine()
from lembas.db.models import Chat, User
owner = User(name="Sam", email="s@shire.test", password_hash="x") # noqa: S106
db.add(owner)
db.commit()
chat = Chat(user_id=owner.id, model_id="m")
db.add(chat)
db.commit()
chat_id = chat.id
db.close()
_rollback_to_0_8_1(engine)
sync_schema(engine)
with engine.connect() as connection:
parent = connection.execute(
text("SELECT parent_chat_id FROM chats WHERE id = :id"), {"id": chat_id}
).scalar()
assert parent is None, "an added foreign key must be absent, not empty"
def test_a_not_null_column_backfills_every_existing_row(db):
"""SQLite refuses to add a NOT NULL column without a default, so one is
derived from the type. `chats.unattended` is a boolean: an old row has to
come back False rather than NULL, or every `if chat.unattended` downstream
reads a null as a value.
"""
engine = get_engine()
from lembas.db.models import Chat, User
owner = User(name="Merry", email="m@shire.test", password_hash="x") # noqa: S106
db.add(owner)
db.commit()
db.add(Chat(user_id=owner.id, model_id="m"))
db.commit()
db.close()
_rollback_to_0_8_1(engine)
sync_schema(engine)
with engine.connect() as connection:
values = connection.execute(text("SELECT unattended FROM chats")).scalars().all()
assert values, "no rows survived the upgrade"
assert all(value in (0, False) for value in values), values
def test_upgrading_twice_changes_nothing_the_second_time(db):
"""It runs on every startup and has to converge. A second pass reporting
work would mean it was re-adding something, which on SQLite is an error that
stops the application booting."""
engine = get_engine()
_rollback_to_0_8_1(engine)
assert sync_schema(engine)
assert sync_schema(engine) == []
def test_the_search_indexes_are_rebuilt_when_missing(db):
"""FTS5 tables are not SQLAlchemy models, so `create_all` cannot make them
and the column differ cannot see them. `ensure_fts` is what converges them,
and it runs at every startup for the same reason the column sync does --
an instance whose indexes were dropped must repair itself rather than
return nothing and call it an empty library.
"""
engine = get_engine()
with engine.begin() as connection:
connection.execute(text("DROP TABLE IF EXISTS documents_fts"))
assert "documents_fts" in ensure_fts(engine)
assert "documents_fts" in inspect(engine).get_table_names()
# Converges: a second call has nothing left to make.
assert ensure_fts(engine) == []
@pytest.mark.parametrize("table", OLD_TABLES)
def test_each_new_table_is_usable_after_the_upgrade(db, table):
"""Created is not the same as correct. A table the differ made but got the
columns wrong on fails at the first insert, which is the first *reply* on an
upgraded instance rather than at startup."""
engine = get_engine()
_rollback_to_0_8_1(engine)
sync_schema(engine)
declared = {column.name for column in Base.metadata.tables[table].c}
assert _columns(engine, table) == declared