Files
homelable/backend/tests/test_documents_migration.py
T
Pouzor d52f698de3 fix(docs): bring an existing documents table up to the current shape
Opening the Documentation section against a database that already had a
`documents` table failed with `no such column: documents.edited_at`.

`Base.metadata.create_all` creates a table that is missing; it never adds a
column to one that is already there. `edited_at` landed after the table had
been created on a machine running the branch, so that database kept the older
shape and every query naming the column failed — including the one behind the
section's first request.

The repo convention is that column additions go through `_try_migrate`, and the
documents tables were the one place leaning on `create_all` for more than the
initial CREATE. Every nullable or defaulted column is now restated in
DOCUMENT_DDL as an idempotent ALTER, the way the `device_inventory` migrations
are, so an existing table is brought up to the current shape at boot whatever
shape it starts from.

The test fixture applied the same DDL with a raw `exec_driver_sql`, which was
fine for pure CREATE statements but turns each of those ALTERs into a fatal
"duplicate column" on the fresh database create_all has just built. It goes
through `_try_migrate` too now — which is what its own comment already claimed
it did.

ha-relevant: no
2026-09-07 10:38:24 +02:00

159 lines
6.3 KiB
Python

"""The documents DDL `create_all` does not apply.
Column additions to an existing table (which `create_all` never makes), plus a
virtual table and three partial indexes (which it cannot express at all), all
applied through `_try_migrate` at boot. Every one has to survive a second boot
untouched, and the partial uniques are what stop two documents claiming the same
device behind the route's own check.
"""
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import create_async_engine
from app.db.database import DOCUMENT_DDL, Base, _try_migrate
pytestmark = pytest.mark.asyncio
async def _boot(tmp_path, times: int = 1):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
for _ in range(times):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
for label, sql in DOCUMENT_DDL:
await _try_migrate(conn, sql, label=label)
return engine
async def _names(engine, kind: str) -> set[str]:
async with engine.begin() as conn:
rows = (
await conn.exec_driver_sql(f"SELECT name FROM sqlite_master WHERE type = '{kind}'")
).fetchall()
return {row[0] for row in rows}
async def test_the_tables_and_the_search_index_are_created(tmp_path):
engine = await _boot(tmp_path)
tables = await _names(engine, "table")
assert {"documents", "document_revisions", "documents_fts"} <= tables
await engine.dispose()
async def test_the_partial_uniques_are_created(tmp_path):
engine = await _boot(tmp_path)
indexes = await _names(engine, "index")
assert {"ux_documents_device", "ux_documents_node", "ux_documents_design"} <= indexes
await engine.dispose()
async def test_a_second_boot_changes_nothing(tmp_path):
engine = await _boot(tmp_path, times=3)
tables = await _names(engine, "table")
assert {"documents", "documents_fts"} <= tables
await engine.dispose()
async def test_two_documents_cannot_claim_the_same_device(tmp_path):
engine = await _boot(tmp_path)
async with engine.begin() as conn:
insert = (
"INSERT INTO documents (id, kind, title, slug, body, device_id, sort_order, "
"frontmatter, tags, starred, created_at, updated_at) "
"VALUES (?, 'device', 'nas', ?, '', 'dev-1', 0, '{}', '[]', 0, "
"'2026-09-05', '2026-09-05')"
)
await conn.exec_driver_sql(insert, ("a", "nas"))
with pytest.raises(IntegrityError):
await conn.exec_driver_sql(insert, ("b", "nas-2"))
await engine.dispose()
async def test_many_documents_may_have_no_device(tmp_path):
engine = await _boot(tmp_path)
async with engine.begin() as conn:
insert = (
"INSERT INTO documents (id, kind, title, slug, body, device_id, sort_order, "
"frontmatter, tags, starred, created_at, updated_at) "
"VALUES (?, 'page', 'p', ?, '', NULL, 0, '{}', '[]', 0, "
"'2026-09-05', '2026-09-05')"
)
await conn.exec_driver_sql(insert, ("a", "p1"))
await conn.exec_driver_sql(insert, ("b", "p2"))
count = (await conn.exec_driver_sql("SELECT COUNT(*) FROM documents")).scalar_one()
assert count == 2
await engine.dispose()
# ── columns added to a table that already exists ─────────────────────────────
# `documents` as an earlier build of the feature created it: no `edited_at`.
_DOCUMENTS_WITHOUT_EDITED_AT = (
"CREATE TABLE documents ("
"id VARCHAR NOT NULL PRIMARY KEY, kind VARCHAR NOT NULL, title VARCHAR NOT NULL, "
"slug VARCHAR NOT NULL, icon VARCHAR, parent_id VARCHAR, sort_order INTEGER, "
"device_id VARCHAR, node_id VARCHAR, design_id VARCHAR, body TEXT NOT NULL, "
"frontmatter JSON, tags JSON, starred BOOLEAN, template_id VARCHAR, "
"facts_snapshot JSON, facts_synced_at DATETIME, reviewed_at DATETIME, "
"created_at DATETIME, updated_at DATETIME)"
)
async def _columns(engine, table: str) -> list[str]:
async with engine.begin() as conn:
rows = (await conn.exec_driver_sql(f"PRAGMA table_info({table})")).fetchall()
return [row[1] for row in rows]
async def test_an_existing_table_gains_the_columns_create_all_would_not_add(tmp_path):
"""The regression: `create_all` makes a missing table, never a missing column.
A database that met an earlier shape of `documents` kept it, and every query
naming `edited_at` failed with ``no such column: documents.edited_at`` —
including the one behind opening the Documentation section.
"""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
async with engine.begin() as conn:
await conn.exec_driver_sql(_DOCUMENTS_WITHOUT_EDITED_AT)
assert "edited_at" not in await _columns(engine, "documents")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) # sees the table, leaves it be
for label, sql in DOCUMENT_DDL:
await _try_migrate(conn, sql, label=label)
assert "edited_at" in await _columns(engine, "documents")
await engine.dispose()
async def test_the_old_rows_survive_the_column_being_added(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
async with engine.begin() as conn:
await conn.exec_driver_sql(_DOCUMENTS_WITHOUT_EDITED_AT)
await conn.exec_driver_sql(
"INSERT INTO documents (id, kind, title, slug, body) "
"VALUES ('d1', 'page', 'Rebuild plan', 'rebuild-plan', '# Rebuild plan')"
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
for label, sql in DOCUMENT_DDL:
await _try_migrate(conn, sql, label=label)
async with engine.begin() as conn:
row = (
await conn.exec_driver_sql("SELECT title, body, edited_at FROM documents WHERE id = 'd1'")
).fetchone()
assert row == ("Rebuild plan", "# Rebuild plan", None)
await engine.dispose()
async def test_a_fresh_database_has_every_column(tmp_path):
engine = await _boot(tmp_path, times=2)
columns = await _columns(engine, "documents")
assert "edited_at" in columns
# The re-stated ALTERs are idempotent: no column is added twice.
assert len(columns) == len(set(columns))
await engine.dispose()