Files
ledgrab/server/tests/storage/test_database_reopen.py
alexei.dolgolyov f591e258f7 fix(storage/database): reopen connection on lifespan restart
Database opened its sqlite3 connection eagerly in __init__ and closed it
in close(); the lifespan called close() on shutdown. In production this
is fine — the lifespan runs once per process. Under pytest the module-
level ``db`` singleton survives across every TestClient session, so the
second test file's lifespan startup hit
``sqlite3.ProgrammingError: Cannot operate on a closed database`` at
fixture-setup time (AutoBackupEngine.__init__ → db.get_setting("…")
was the first reader). 65 spurious "errors" on a full Windows pytest run.

- Database: extract _open() from __init__, add ensure_open() that
  reopens iff _conn is None, and have close() null _conn after the
  TRUNCATE checkpoint so re-close is idempotent.
- main.py lifespan startup: call db.ensure_open() before any setting
  read, so subsequent TestClient sessions get a live connection.
- tests/storage/test_database_reopen.py: pin the four invariants —
  close→ensure_open round-trips data, ensure_open is a no-op when
  open, close is idempotent, and using the DB after close without
  ensure_open raises (callers must opt in).

Full backend suite: 1551 pass / 1 skip / 0 errors. Ruff clean.
2026-05-26 00:26:36 +03:00

63 lines
1.9 KiB
Python

"""Regression coverage for the lifespan reopen path on ``Database``.
The production server only runs a single FastAPI lifespan per process, so
``Database.close()`` releasing the connection is fine. Pytest is different:
``ledgrab.main`` is imported once and its module-level ``db`` singleton
survives across every TestClient session, each of which closes the
connection on shutdown.
Before :meth:`Database.ensure_open` existed, the second TestClient session
hit ``sqlite3.ProgrammingError: Cannot operate on a closed database`` at
fixture-setup time when the lifespan startup tried to read settings (the
``AutoBackupEngine`` constructor was the first reader). These tests pin
the close+reopen cycle so the cascade can't silently come back.
"""
import sqlite3
import pytest
from ledgrab.storage.database import Database
def test_close_then_ensure_open_reopens_connection(tmp_path):
db = Database(tmp_path / "reopen.db")
db.set_setting("k", {"v": 1})
db.close()
db.ensure_open()
assert db.get_setting("k") == {"v": 1}
db.close()
def test_ensure_open_is_idempotent_when_already_open(tmp_path):
db = Database(tmp_path / "idempotent.db")
original_conn = db._conn
db.ensure_open()
# Same live connection — no spurious reconnect when already open.
assert db._conn is original_conn
db.close()
def test_close_is_idempotent(tmp_path):
db = Database(tmp_path / "double_close.db")
db.close()
# Second close must not raise (lifespan shutdown can run twice in
# quick test sessions). Connection stays released.
db.close()
assert db._conn is None
def test_operation_after_close_without_reopen_raises(tmp_path):
db = Database(tmp_path / "no_reopen.db")
db.close()
# Without ensure_open, attempting to use the DB fails loudly rather
# than silently re-connecting — callers must opt in.
with pytest.raises((sqlite3.ProgrammingError, AttributeError)):
db.get_setting("anything")