mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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.
197 lines
7.2 KiB
Python
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"]
|