Files
bambuddy/backend/tests/unit/test_billing_run_id_migration.py
T
maziggy bf525661d3 Three fixes on top of the billing branch, all found by running the suite against
both dialects rather than one.

Postgres upgrades never got as far as the finance schema.

  database.py added on_billing_charge_failed with BOOLEAN DEFAULT 1. The 1 is a
  SQLite-ism; Postgres answers DatatypeMismatchError, and _safe_execute
  deliberately re-raises anything that is not an idempotency error, so
  run_migrations died there and rolled the whole transaction back. No finance
  tables, no columns, and the app does not start. Six lines above, the same
  change gets is_voided right with an is_sqlite() branch, so this was an
  oversight rather than a decision. Now branched the same way.

  This also explains the four test_security.py::TestBackupKeyFiles failures
  reporting "column print_archives.cost_center_id does not exist". That column's
  migration exists and works -- it simply never ran, because every startup
  aborted before committing. Reproduced against Postgres 16 by building a
  pre-billing schema from dev and upgrading over it: fails without this,
  completes with it, and re-running the migrations or starting from an empty
  database are both clean.

test_billing_run_id_migration.py failed on any Postgres-configured checkout.

  It builds its own SQLite engine, but run_migrations branches on the global
  dialect rather than the connection in hand, so on a box whose DATABASE_URL
  points at Postgres it emitted md5(random()::text) and btrim() into SQLite.
  Given the same fixture test_ldap_migration.py already carries for exactly this
  reason. The suite now agrees across dialects -- 9190 passed either way, where
  it used to be 9184 on one and 9183 on the other.

The kill switch could not tell a print Bambuddy started from one it merely
watched.

  Authorization fell back to a print_archives row in status="printing" matched on
  subtask_id. But on_print_start archives every print it observes, including ones
  started from Bambu Studio or Handy, and stamps them with the same status and
  subtask_id -- the code says as much where it notes "a print Bambuddy didn't
  dispatch". So a foreign print became authorized the moment its 3MF finished
  downloading, and _active_prints was rehydrated from it, making that permanent.
  The switch fired only inside the download race, and never afterwards. Neither
  test caught it: one stubs the authorization call to False, the other stubs the
  query to return an archive, so the real lookup was never exercised against a
  foreign print.

  Authorization now requires a marker Bambuddy writes itself: billing_run_id,
  minted per dispatch in the scheduler, or created_by_id carried over from the
  queue item. Failing that, it looks for a queue row in status="printing" on that
  printer -- committed before the MQTT send, and the only durable trace a
  library-file dispatch leaves, since those have no archive at send time and the
  row created for them moments later carries neither marker. That row cannot be
  tied to a subtask_id, so it defers rather than authorizes.

  Deferring also closes a false positive the previous version shared: a restart
  in the window between the send and the download left no archive at all, and a
  Bambuddy print was stopped as unauthorized. Stopping a print is irreversible
  and declining to act costs a log line, so ambiguity resolves that way.

  Tests cover an unmarked archive not being authorization and not entering
  _active_prints, either marker alone authorizing and rehydrating the fast path
  without touching the queue, an unmarked archive with a live dispatch deferring,
  a dispatch not yet archived deferring, and nothing at all being unauthorized.
2026-08-08 11:19:33 +02:00

53 lines
2.5 KiB
Python

"""Migration coverage for durable per-dispatch billing identities."""
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
import backend.app.models.external_link # noqa: F401 - required by a legacy ALTER in run_migrations
import backend.app.models.print_log # noqa: F401 - required by a legacy ALTER in run_migrations
from backend.app.core.database import Base, run_migrations
@pytest.fixture(autouse=True)
def force_sqlite_dialect(monkeypatch):
"""The engine below is SQLite, but settings.database_url may point at Postgres in a
dev config — and run_migrations branches on the global dialect, not on the
connection. Without this the Postgres branch runs against SQLite and the migration
fails on Postgres-only syntax. Same fixture as test_ldap_migration.py."""
from backend.app.core import db_dialect
monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
# database.py imported is_sqlite at module load time — patch there too.
from backend.app.core import database as database_module
monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
@pytest.mark.asyncio
async def test_billing_run_columns_and_legacy_archive_index_are_migrated(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'billing-run.db'}")
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_migrations(conn)
queue_columns = {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_queue)"))).all()}
archive_columns = {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all()}
notification_columns = {
row[1] for row in (await conn.execute(text("PRAGMA table_info(notification_providers)"))).all()
}
archive_index_sql = await conn.scalar(
text("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'uq_wallet_transactions_archive'")
)
assert "billing_run_id" in queue_columns
assert "billing_run_id" in archive_columns
assert "on_billing_charge_failed" in notification_columns
assert archive_index_sql is not None
assert "WHERE print_run_id IS NULL" in archive_index_sql
finally:
await engine.dispose()