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
159 lines
6.3 KiB
Python
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()
|