Files
bambuddy/backend/tests/unit/test_wallet_currency_drop_migration.py
maziggy 4a85e033c0 fix(finance): show the currency the install is configured for (issue #3123)
The Finance page was the only surface in Bambuddy that read its currency
from a data row rather than the `currency` setting, and it fell back to EUR
where every other page falls back to USD. One variable drives every amount
on that page, so the personal balance, the cost-center budgets and the whole
transaction list were wrong together on any install not set to euros. It now
takes the configured currency from /settings/ui-flags, which is readable by
anyone who can see Finance -- /settings needs SETTINGS_READ, which a
cost_centers:read_own user does not have.

The backend was the other half. Of the four places that settle on a
currency, three wrote a hardcoded "EUR": the wallet the API mints on demand,
the wallet a print charge mints when none exists, and the balance returned
for a user with no wallet row at all. All four now go through one resolver,
which lives beside the rest of the balance logic.

The wallet's currency column is removed outright rather than merely ignored.
An install has one currency and nothing here converts between them, so a
per-wallet copy could only ever drift from the setting -- and a column
nothing reads is a trap for whoever finds it next. A startup migration drops
it on both SQLite and PostgreSQL, after the raw CREATE TABLE that would
otherwise re-add it on an install whose finance tables predate the ORM.
SQLite builds older than 3.35 have no DROP COLUMN and keep it, harmlessly,
since it has a default and no reader.

Saving settings now invalidates the ui-flags query too. Nothing did, so a
changed currency sat behind that query's staleTime before showing up. The
sponsor prompt's own EUR fallback is now USD, matching AppSettings.
2026-09-20 10:06:47 +02:00

197 lines
7.2 KiB
Python

"""Migration coverage for removing user_wallets.currency (#3123).
An install has one currency, held in the ``currency`` app setting. The column
recorded whatever was configured when a wallet row happened to be created, and
three of its four writers hardcoded "EUR", so it could only ever disagree with
the setting. Dropping it from the model alone would leave every existing
database carrying a column nothing reads.
"""
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import Base, run_migrations
def _register_all_models():
"""run_migrations touches many tables; the whole schema has to exist.
Same list as test_vp_mode_rename_migration.py -- importing only the finance
models leaves run_migrations ALTERing tables create_all never built.
"""
from backend.app.models import ( # noqa: F401
ams_history,
ams_label,
api_key,
archive,
color_catalog,
external_link,
filament,
finance,
group,
kprofile_note,
maintenance,
notification,
notification_template,
print_log,
print_queue,
printer,
project,
project_bom,
settings,
slot_preset,
smart_plug,
smart_plug_energy_snapshot,
spool,
spool_assignment,
spool_catalog,
spool_k_profile,
spool_usage_history,
spoolbuddy_device,
user,
user_email_pref,
virtual_printer,
)
@pytest.fixture(autouse=True)
def force_sqlite_dialect(monkeypatch):
"""run_migrations branches on the global dialect, not on the connection.
settings.database_url may point at Postgres in a dev config, which would
run the Postgres branch against the SQLite engine below. Same fixture as
test_billing_run_id_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)
async def _wallet_columns(conn) -> set[str]:
return {row[1] for row in (await conn.execute(text("PRAGMA table_info(user_wallets)"))).all()}
@pytest.mark.asyncio
async def test_an_existing_currency_column_is_dropped(tmp_path):
"""The upgrade path: a database that predates the fix."""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-currency.db'}")
try:
async with engine.begin() as conn:
_register_all_models()
await conn.run_sync(Base.metadata.create_all)
# Recreate the pre-#3123 shape, balance and all, then prove the
# migration takes the column without taking the row with it.
await conn.execute(text("ALTER TABLE user_wallets ADD COLUMN currency VARCHAR(3) NOT NULL DEFAULT 'EUR'"))
await conn.execute(text("INSERT INTO user_wallets (user_id, balance, currency) VALUES (7, 12.34, 'EUR')"))
assert "currency" in await _wallet_columns(conn)
await run_migrations(conn)
assert "currency" not in await _wallet_columns(conn)
row = (await conn.execute(text("SELECT user_id, balance FROM user_wallets"))).all()
assert row == [(7, 12.34)]
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_the_legacy_create_table_does_not_declare_it(tmp_path):
"""_migrate_create_finance_tables carries its own raw CREATE TABLE.
It exists for installs whose finance tables predate the ORM models, and it
declared the column independently of the model. Exercised on its own here,
without the drop migration that would otherwise mask it.
"""
from backend.app.core.database import _migrate_create_finance_tables
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-legacy.db'}")
try:
async with engine.begin() as conn:
_register_all_models()
await conn.run_sync(Base.metadata.create_all)
await conn.execute(text("DROP TABLE user_wallets"))
await _migrate_create_finance_tables(conn)
assert await _wallet_columns(conn), "the legacy path must still create the table"
assert "currency" not in await _wallet_columns(conn)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_the_migration_is_idempotent(tmp_path):
"""Startup runs it every time; the second pass must not error."""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-twice.db'}")
try:
async with engine.begin() as conn:
_register_all_models()
await conn.run_sync(Base.metadata.create_all)
await conn.execute(text("ALTER TABLE user_wallets ADD COLUMN currency VARCHAR(3) NOT NULL DEFAULT 'EUR'"))
await run_migrations(conn)
await run_migrations(conn)
assert "currency" not in await _wallet_columns(conn)
finally:
await engine.dispose()
class _AsyncCtxStub:
"""Async context manager that does nothing — for ``begin_nested()``."""
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
async def _capture_drop_sql(is_sqlite_value: bool) -> list[str]:
"""Every DROP COLUMN statement run_migrations would issue on this dialect.
The project's suite runs on SQLite, so the PostgreSQL branch is otherwise
dead code in CI. Same capture pattern as test_oidc_icon_migration_pg.py.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from backend.app.core import database as db_module
executed: list[str] = []
async def fake_safe_execute(_conn, sql: str) -> None:
executed.append(sql)
fake_conn = MagicMock()
fake_conn.begin_nested = lambda: _AsyncCtxStub()
fake_conn.execute = AsyncMock(return_value=MagicMock(fetchone=MagicMock(return_value=None)))
with (
patch("backend.app.core.database.is_sqlite", return_value=is_sqlite_value),
patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
patch("backend.app.core.database._migrate_update_auto_link_constraint", AsyncMock()),
patch("backend.app.core.database._migrate_widen_spoolman_slot_ams_id_range", AsyncMock()),
):
await db_module.run_migrations(fake_conn)
return [sql for sql in executed if "user_wallets" in sql and "DROP COLUMN" in sql]
@pytest.mark.asyncio
async def test_postgres_drops_it_conditionally():
"""PostgreSQL takes IF EXISTS, which SQLite's DROP COLUMN does not accept."""
statements = await _capture_drop_sql(is_sqlite_value=False)
assert statements == ["ALTER TABLE user_wallets DROP COLUMN IF EXISTS currency"]
@pytest.mark.asyncio
async def test_sqlite_drops_it_plainly():
"""Companion to the PostgreSQL case, so the dialect switch cannot invert."""
statements = await _capture_drop_sql(is_sqlite_value=True)
assert statements == ["ALTER TABLE user_wallets DROP COLUMN currency"]