test_finance_table_migration.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. """Regression tests for finance tables on upgraded databases."""
  2. import os
  3. from unittest.mock import patch
  4. import pytest
  5. from sqlalchemy import text
  6. from sqlalchemy.ext.asyncio import create_async_engine
  7. from backend.app.core.database import _migrate_create_finance_indexes, _migrate_create_finance_tables
  8. EXPECTED_TABLES = {
  9. "cost_centers",
  10. "wallet_transactions",
  11. "budget_reservations",
  12. "cost_center_members",
  13. "cost_center_invitations",
  14. "user_wallets",
  15. }
  16. @pytest.mark.asyncio
  17. async def test_finance_tables_are_created_idempotently_on_sqlite():
  18. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  19. try:
  20. async with engine.begin() as conn:
  21. with patch("backend.app.core.database.is_sqlite", return_value=True):
  22. await _migrate_create_finance_tables(conn)
  23. await _migrate_create_finance_tables(conn)
  24. rows = await conn.execute(
  25. text(
  26. "SELECT name FROM sqlite_master "
  27. "WHERE type = 'table' AND name IN "
  28. "('cost_centers', 'wallet_transactions', 'budget_reservations', "
  29. "'cost_center_members', 'cost_center_invitations', 'user_wallets')"
  30. )
  31. )
  32. assert {row[0] for row in rows} == EXPECTED_TABLES
  33. finally:
  34. await engine.dispose()
  35. @pytest.mark.asyncio
  36. async def test_legacy_cost_center_indexes_are_delayed_until_columns_exist():
  37. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  38. try:
  39. async with engine.begin() as conn:
  40. await conn.execute(text("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, name VARCHAR(150) NOT NULL)"))
  41. with patch("backend.app.core.database.is_sqlite", return_value=True):
  42. await _migrate_create_finance_tables(conn)
  43. await conn.execute(text("ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)"))
  44. await _migrate_create_finance_indexes(conn)
  45. result = await conn.execute(
  46. text("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'ix_cost_centers_code'")
  47. )
  48. assert result.scalar_one() == "ix_cost_centers_code"
  49. finally:
  50. await engine.dispose()
  51. @pytest.mark.asyncio
  52. async def test_postgres_finance_ddl_uses_postgres_types():
  53. statements: list[str] = []
  54. async def capture_statement(_conn, sql: str) -> None:
  55. statements.append(sql)
  56. with (
  57. patch("backend.app.core.database.is_sqlite", return_value=False),
  58. patch("backend.app.core.database._safe_execute", side_effect=capture_statement),
  59. ):
  60. await _migrate_create_finance_tables(object())
  61. create_statements = [sql for sql in statements if "CREATE TABLE" in sql]
  62. assert len(create_statements) == len(EXPECTED_TABLES)
  63. assert all("IF NOT EXISTS" in sql for sql in create_statements)
  64. assert all("DATETIME" not in sql for sql in create_statements)
  65. assert all("id SERIAL PRIMARY KEY" in sql for sql in create_statements)
  66. assert "TIMESTAMP" in "\n".join(create_statements)
  67. created_tables = {
  68. sql.split("CREATE TABLE IF NOT EXISTS", 1)[1].split("(", 1)[0].strip() for sql in create_statements
  69. }
  70. assert created_tables == EXPECTED_TABLES
  71. @pytest.mark.asyncio
  72. async def test_finance_tables_are_created_idempotently_on_postgres():
  73. database_url = os.getenv("BAMBUDDY_TEST_POSTGRES_URL")
  74. if not database_url:
  75. pytest.skip("BAMBUDDY_TEST_POSTGRES_URL is not configured")
  76. engine = create_async_engine(database_url)
  77. try:
  78. async with engine.begin() as conn:
  79. # Minimal pre-billing schema: these are the only tables referenced
  80. # by foreign keys in the new finance tables.
  81. await conn.execute(text("CREATE TABLE users (id SERIAL PRIMARY KEY)"))
  82. await conn.execute(text("CREATE TABLE print_archives (id SERIAL PRIMARY KEY)"))
  83. await conn.execute(text("CREATE TABLE print_queue (id SERIAL PRIMARY KEY)"))
  84. with patch("backend.app.core.database.is_sqlite", return_value=False):
  85. await _migrate_create_finance_tables(conn)
  86. await _migrate_create_finance_tables(conn)
  87. await _migrate_create_finance_indexes(conn)
  88. await _migrate_create_finance_indexes(conn)
  89. rows = await conn.execute(
  90. text(
  91. "SELECT table_name FROM information_schema.tables "
  92. "WHERE table_schema = 'public' AND table_name = ANY(:tables)"
  93. ),
  94. {"tables": sorted(EXPECTED_TABLES)},
  95. )
  96. timestamp_type = await conn.execute(
  97. text(
  98. "SELECT data_type FROM information_schema.columns "
  99. "WHERE table_schema = 'public' "
  100. "AND table_name = 'cost_centers' AND column_name = 'created_at'"
  101. )
  102. )
  103. assert {row[0] for row in rows} == EXPECTED_TABLES
  104. assert timestamp_type.scalar_one() == "timestamp without time zone"
  105. finally:
  106. await engine.dispose()