test_finance_table_migration.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  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 (
  8. _migrate_add_print_archive_cost_center,
  9. _migrate_create_finance_indexes,
  10. _migrate_create_finance_tables,
  11. )
  12. EXPECTED_TABLES = {
  13. "cost_centers",
  14. "wallet_transactions",
  15. "budget_reservations",
  16. "cost_center_members",
  17. "user_wallets",
  18. }
  19. @pytest.mark.asyncio
  20. async def test_finance_tables_are_created_idempotently_on_sqlite():
  21. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  22. try:
  23. async with engine.begin() as conn:
  24. with patch("backend.app.core.database.is_sqlite", return_value=True):
  25. await _migrate_create_finance_tables(conn)
  26. await _migrate_create_finance_tables(conn)
  27. rows = await conn.execute(
  28. text(
  29. "SELECT name FROM sqlite_master "
  30. "WHERE type = 'table' AND name IN "
  31. "('cost_centers', 'wallet_transactions', 'budget_reservations', "
  32. "'cost_center_members', 'user_wallets')"
  33. )
  34. )
  35. invitation_table = await conn.scalar(
  36. text("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cost_center_invitations'")
  37. )
  38. wallet_columns = await conn.execute(text("PRAGMA table_info(user_wallets)"))
  39. transaction_columns = await conn.execute(text("PRAGMA table_info(wallet_transactions)"))
  40. assert {row[0] for row in rows} == EXPECTED_TABLES
  41. assert invitation_table is None
  42. assert {row[1]: row[2] for row in wallet_columns}["balance"] == "NUMERIC(14,2)"
  43. transaction_types = {row[1]: row[2] for row in transaction_columns}
  44. assert transaction_types["amount"] == "NUMERIC(14,2)"
  45. assert transaction_types["balance_after"] == "NUMERIC(14,2)"
  46. assert transaction_types["is_voided"] == "BOOLEAN"
  47. finally:
  48. await engine.dispose()
  49. @pytest.mark.asyncio
  50. async def test_legacy_cost_center_indexes_are_delayed_until_columns_exist():
  51. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  52. try:
  53. async with engine.begin() as conn:
  54. await conn.execute(text("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, name VARCHAR(150) NOT NULL)"))
  55. with patch("backend.app.core.database.is_sqlite", return_value=True):
  56. await _migrate_create_finance_tables(conn)
  57. await conn.execute(text("ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)"))
  58. await _migrate_create_finance_indexes(conn)
  59. result = await conn.execute(
  60. text("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'ix_cost_centers_code'")
  61. )
  62. assert result.scalar_one() == "ix_cost_centers_code"
  63. finally:
  64. await engine.dispose()
  65. @pytest.mark.asyncio
  66. async def test_print_archive_cost_center_is_added_idempotently_on_sqlite():
  67. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  68. try:
  69. async with engine.begin() as conn:
  70. await conn.execute(text("PRAGMA foreign_keys = ON"))
  71. await conn.execute(text("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY)"))
  72. await conn.execute(text("CREATE TABLE print_archives (id INTEGER PRIMARY KEY)"))
  73. await _migrate_add_print_archive_cost_center(conn)
  74. await _migrate_add_print_archive_cost_center(conn)
  75. columns = await conn.execute(text("PRAGMA table_info(print_archives)"))
  76. foreign_keys = await conn.execute(text("PRAGMA foreign_key_list(print_archives)"))
  77. assert "cost_center_id" in {row[1] for row in columns}
  78. assert any(
  79. row[2] == "cost_centers" and row[3] == "cost_center_id" and row[6].upper() == "SET NULL"
  80. for row in foreign_keys
  81. )
  82. finally:
  83. await engine.dispose()
  84. @pytest.mark.asyncio
  85. async def test_postgres_finance_ddl_uses_postgres_types():
  86. statements: list[str] = []
  87. async def capture_statement(_conn, sql: str) -> None:
  88. statements.append(sql)
  89. with (
  90. patch("backend.app.core.database.is_sqlite", return_value=False),
  91. patch("backend.app.core.database._safe_execute", side_effect=capture_statement),
  92. ):
  93. await _migrate_create_finance_tables(object())
  94. create_statements = [sql for sql in statements if "CREATE TABLE" in sql]
  95. assert len(create_statements) == len(EXPECTED_TABLES)
  96. assert all("IF NOT EXISTS" in sql for sql in create_statements)
  97. assert all("DATETIME" not in sql for sql in create_statements)
  98. assert all("id SERIAL PRIMARY KEY" in sql for sql in create_statements)
  99. assert "TIMESTAMP" in "\n".join(create_statements)
  100. assert "NUMERIC(14,2)" in "\n".join(create_statements)
  101. assert "is_voided BOOLEAN NOT NULL DEFAULT FALSE" in "\n".join(create_statements)
  102. created_tables = {
  103. sql.split("CREATE TABLE IF NOT EXISTS", 1)[1].split("(", 1)[0].strip() for sql in create_statements
  104. }
  105. assert created_tables == EXPECTED_TABLES
  106. @pytest.mark.asyncio
  107. async def test_finance_tables_are_created_idempotently_on_postgres():
  108. database_url = os.getenv("BAMBUDDY_TEST_POSTGRES_URL")
  109. if not database_url:
  110. pytest.skip("BAMBUDDY_TEST_POSTGRES_URL is not configured")
  111. engine = create_async_engine(database_url)
  112. try:
  113. async with engine.begin() as conn:
  114. # Minimal pre-billing schema: these are the only tables referenced
  115. # by foreign keys in the new finance tables.
  116. await conn.execute(text("CREATE TABLE users (id SERIAL PRIMARY KEY)"))
  117. await conn.execute(text("CREATE TABLE print_archives (id SERIAL PRIMARY KEY)"))
  118. await conn.execute(text("CREATE TABLE print_queue (id SERIAL PRIMARY KEY)"))
  119. with patch("backend.app.core.database.is_sqlite", return_value=False):
  120. await _migrate_create_finance_tables(conn)
  121. await _migrate_create_finance_tables(conn)
  122. await _migrate_add_print_archive_cost_center(conn)
  123. await _migrate_add_print_archive_cost_center(conn)
  124. await _migrate_create_finance_indexes(conn)
  125. await _migrate_create_finance_indexes(conn)
  126. rows = await conn.execute(
  127. text(
  128. "SELECT table_name FROM information_schema.tables "
  129. "WHERE table_schema = 'public' AND table_name = ANY(:tables)"
  130. ),
  131. {"tables": sorted(EXPECTED_TABLES)},
  132. )
  133. timestamp_type = await conn.execute(
  134. text(
  135. "SELECT data_type FROM information_schema.columns "
  136. "WHERE table_schema = 'public' "
  137. "AND table_name = 'cost_centers' AND column_name = 'created_at'"
  138. )
  139. )
  140. archive_cost_center = await conn.execute(
  141. text(
  142. "SELECT c.data_type, rc.delete_rule "
  143. "FROM information_schema.columns c "
  144. "JOIN information_schema.key_column_usage kcu "
  145. " ON kcu.table_schema = c.table_schema "
  146. " AND kcu.table_name = c.table_name "
  147. " AND kcu.column_name = c.column_name "
  148. "JOIN information_schema.referential_constraints rc "
  149. " ON rc.constraint_schema = kcu.constraint_schema "
  150. " AND rc.constraint_name = kcu.constraint_name "
  151. "WHERE c.table_schema = 'public' "
  152. "AND c.table_name = 'print_archives' "
  153. "AND c.column_name = 'cost_center_id'"
  154. )
  155. )
  156. assert {row[0] for row in rows} == EXPECTED_TABLES
  157. assert timestamp_type.scalar_one() == "timestamp without time zone"
  158. assert archive_cost_center.one() == ("integer", "SET NULL")
  159. finally:
  160. await engine.dispose()