test_finance_table_migration.py 10 KB

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