test_finance_table_migration.py 7.2 KB

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