Coverage for app/backend/src/tests/conftest.py: 97%
194 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1import os
2import re
3from collections.abc import Generator
4from tempfile import TemporaryDirectory
5from typing import Any
6from unittest.mock import patch
8import pytest
9from sqlalchemy import Connection, Engine
10from sqlalchemy.sql import text
12# Set up environment variables before any couchers imports (they trigger config loading)
13prometheus_multiproc_dir = TemporaryDirectory()
14os.environ["PROMETHEUS_MULTIPROC_DIR"] = prometheus_multiproc_dir.name
16# Default for running with a database from docker-compose.test.yml.
17if "DATABASE_CONNECTION_STRING" not in os.environ: # pragma: no cover
18 os.environ["DATABASE_CONNECTION_STRING"] = (
19 "postgresql://postgres:06b3890acd2c235c41be0bbfe22f1b386a04bf02eedf8c977486355616be2aa1@localhost:6544/testdb"
20 )
22from couchers import experimentation # noqa: E402
23from couchers.config import config # noqa: E402
24from couchers.models import Base # noqa: E402
25from tests.fixtures.db import ( # noqa: E402
26 autocommit_engine,
27 create_schema_from_models,
28 generate_user,
29 populate_testing_resources,
30)
31from tests.fixtures.misc import EmailCollector, Moderator, PushCollector # noqa: E402
34@pytest.fixture(scope="session")
35def postgres_engine() -> Generator[Engine]:
36 """
37 SQLAlchemy engine connected to "postgres" database.
38 """
39 dsn = config.DATABASE_CONNECTION_STRING
40 if not dsn.endswith("/testdb"): 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true
41 raise RuntimeError(f"DATABASE_CONNECTION_STRING must point to /testdb, but was {dsn}")
43 postgres_dsn = re.sub(r"/testdb$", "/postgres", dsn)
45 with autocommit_engine(postgres_dsn) as engine:
46 yield engine
49@pytest.fixture(scope="session")
50def postgres_conn(postgres_engine: Engine) -> Generator[Connection]:
51 """
52 Acquiring a connection takes time, so we cache it.
53 """
54 with postgres_engine.connect() as conn:
55 yield conn
58@pytest.fixture(scope="session")
59def testdb_engine() -> Generator[Engine]:
60 """
61 SQLAlchemy engine connected to "testdb" database.
62 """
63 dsn = config.DATABASE_CONNECTION_STRING
64 with autocommit_engine(dsn) as engine:
65 yield engine
68@pytest.fixture(scope="session")
69def testdb_conn(testdb_engine: Engine) -> Generator[Connection]:
70 """
71 Connection to testdb for truncating tables between tests.
72 """
73 with testdb_engine.connect() as conn:
74 yield conn
77# Static tables that should not be truncated between tests
78STATIC_TABLES = frozenset({"languages", "timezone_areas", "regions"})
81@pytest.fixture(scope="session")
82def setup_testdb(postgres_conn: Connection, testdb_engine: Engine) -> None:
83 """
84 Creates the test database with all the extensions, tables,
85 and static data (languages, regions, timezones). This is done only once
86 per session. Between tests, we truncate all non-static tables.
87 """
88 # running in non-UTC catches some timezone errors
89 os.environ["TZ"] = "America/New_York"
91 postgres_conn.execute(text("DROP DATABASE IF EXISTS testdb WITH (FORCE)"))
92 postgres_conn.execute(text("CREATE DATABASE testdb"))
94 with testdb_engine.connect() as conn:
95 conn.execute(
96 text(
97 "CREATE SCHEMA logging;"
98 "CREATE EXTENSION IF NOT EXISTS postgis;"
99 "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
100 "CREATE EXTENSION IF NOT EXISTS btree_gist;"
101 "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
102 )
103 )
105 create_schema_from_models(testdb_engine)
106 populate_testing_resources(conn)
109def _truncate_non_static_tables(conn: Connection) -> None:
110 """
111 Truncates all non-static tables.
112 Static tables (languages, timezone_areas, regions) are preserved.
113 """
114 tables_to_truncate = []
115 for name in Base.metadata.tables.keys():
116 # Skip static tables
117 if name in STATIC_TABLES:
118 continue
119 # Handle schema-qualified names (e.g., "logging.api_calls" -> logging."api_calls")
120 if "." in name:
121 schema, table = name.split(".", 1)
122 tables_to_truncate.append(f'{schema}."{table}"')
123 else:
124 tables_to_truncate.append(f'"{name}"')
125 if tables_to_truncate: 125 ↛ 130line 125 didn't jump to line 130 because the condition on line 125 was always true
126 conn.execute(text(f"TRUNCATE {', '.join(tables_to_truncate)} RESTART IDENTITY CASCADE"))
128 # Reset standalone sequences, not owned by any table column
129 # (RESTART IDENTITY only resets sequences owned by truncated columns)
130 conn.execute(text("ALTER SEQUENCE communities_seq RESTART WITH 1"))
131 conn.execute(text("ALTER SEQUENCE moderation_seq RESTART WITH 2000000"))
134@pytest.fixture
135def db(setup_testdb: None, testdb_conn: Connection) -> None:
136 """
137 Truncates all non-static tables before each test.
138 Static tables (languages, timezone_areas, regions) are preserved.
139 """
140 _truncate_non_static_tables(testdb_conn)
143@pytest.fixture(scope="class")
144def db_class(setup_testdb: None, testdb_conn: Connection) -> None:
145 """
146 The same as above, but with a different scope. Used in test_communities.py.
147 """
148 _truncate_non_static_tables(testdb_conn)
151# Production gates forced True so tests run as "everything enabled". Used by testconfig and the `flags`
152# fixture; tests flip individual values via `flags`.
153_TEST_FLAG_DEFAULTS: dict[str, Any] = {
154 "test_growthbook_integration": True,
155 "sms_enabled": True,
156 "strong_verification_enabled": True,
157 "log_native_ota_requests": True,
158 "donations_enabled": True,
159 "antibot_enabled": True,
160 "postal_verification_enabled": True,
161 "listmonk_enabled": True,
162 "remove_removed_users_from_mailing_list_enabled": True,
163 "notification_translations_enabled": True,
164 "email_ics_attachments_enabled": True,
165 "public_trips_enabled": True,
166}
169@pytest.fixture(scope="class")
170def testconfig():
171 prevconfig = config.copy()
172 prev_initialized = experimentation._initialized
173 prev_load_local_flags = experimentation._load_local_flags
175 config.IN_TEST = True
177 config.DEV = True
178 config.SECRET = bytes.fromhex("448697d3886aec65830a1ea1497cdf804981e0c260d2f812cf2787c4ed1a262b")
179 config.VERSION = "testing_version"
180 config.BASE_URL = "http://localhost:3000"
181 config.BACKEND_BASE_URL = "http://localhost:8888"
182 config.CONSOLE_BASE_URL = "http://localhost:8888"
183 config.COOKIE_DOMAIN = "localhost"
185 config.SMS_SENDER_ID = "invalid"
187 config.ENABLE_EMAIL = False
188 config.NOTIFICATION_EMAIL_SENDER = "Couchers.org"
189 config.NOTIFICATION_EMAIL_ADDRESS = "notify@couchers.org.invalid"
190 config.NOTIFICATION_PREFIX = "[TEST] "
191 config.REPORTS_EMAIL_RECIPIENT = "reports@couchers.org.invalid"
192 config.CONTRIBUTOR_FORM_EMAIL_RECIPIENT = "forms@couchers.org.invalid"
193 config.MODS_EMAIL_RECIPIENT = "mods@couchers.org.invalid"
195 config.STRIPE_API_KEY = ""
196 config.STRIPE_WEBHOOK_SECRET = ""
197 config.STRIPE_RECURRING_PRODUCT_ID = ""
199 config.IRIS_ID_PUBKEY = ""
200 config.IRIS_ID_SECRET = ""
201 # corresponds to private key e6c2fbf3756b387bc09a458a7b85935718ef3eb1c2777ef41d335c9f6c0ab272
202 config.VERIFICATION_DATA_PUBLIC_KEY = bytes.fromhex(
203 "dd740a2b2a35bf05041a28257ea439b30f76f056f3698000b71e6470cd82275f"
204 )
206 config.MYPOSTCARD_API_KEY = "test-api-key"
207 config.MYPOSTCARD_USERNAME = "test-username"
208 config.MYPOSTCARD_PASSWORD = "test-password"
209 config.MYPOSTCARD_PRODUCT_CODE = "J9GCU"
210 config.MYPOSTCARD_CAMPAIGN_ID = "295"
212 config.SMTP_HOST = "localhost"
213 config.SMTP_PORT = 587
214 config.SMTP_USERNAME = "username"
215 config.SMTP_PASSWORD = "password"
217 config.ENABLE_MEDIA = True
218 config.MEDIA_SERVER_SECRET_KEY = bytes.fromhex("91e29bbacc74fa7e23c5d5f34cca5015cb896e338a620003de94a502a461f4bc")
219 config.MEDIA_SERVER_BEARER_TOKEN = "c02d383897d3b82774ced09c9e17802164c37e7e105d8927553697bf4550e91e"
220 config.MEDIA_SERVER_BASE_URL = "http://localhost:5001"
221 config.MEDIA_SERVER_UPLOAD_BASE_URL = "http://localhost:5001"
223 config.BUG_TOOL_ENABLED = False
224 config.BUG_TOOL_GITHUB_REPO = "org/repo"
225 config.BUG_TOOL_GITHUB_USERNAME = "user"
226 config.BUG_TOOL_GITHUB_TOKEN = "token"
228 config.LISTMONK_BASE_URL = "https://localhost"
229 config.LISTMONK_API_USERNAME = "..."
230 config.LISTMONK_API_KEY = "..."
231 config.LISTMONK_LIST_ID = 3
233 config.PUSH_NOTIFICATIONS_ENABLED = True
234 config.PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY = "uI1DCR4G1AdlmMlPfRLemMxrz9f3h4kvjfnI8K9WsVI"
235 config.PUSH_NOTIFICATIONS_VAPID_SUBJECT = "mailto:testing@couchers.org.invalid"
237 config.ACTIVENESS_PROBES_ENABLED = True
239 # File-override mode; gates forced True via the stubbed loader below. Tests needing GrowthBook use `feature_flags`.
240 config.FEATURE_FLAGS_FILE_OVERRIDE_PATH = "feature-flags.dev.json"
241 config.GROWTHBOOK_API_HOST = "https://cdn.growthbook.io"
242 config.GROWTHBOOK_CLIENT_KEY = ""
243 config.GROWTHBOOK_CACHE_PATH = ""
244 experimentation._initialized = True
245 experimentation._load_local_flags = lambda _path: _TEST_FLAG_DEFAULTS # type: ignore[assignment]
247 # Moderation auto-approval deadline - 0 disables, set in tests that need it
248 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0
249 # Bot user ID for automated moderation - will be set to a real user in tests that need it
250 config.MODERATION_BOT_USER_ID = 1
252 # Dev APIs disabled by default in tests
253 config.ENABLE_DEV_APIS = False
255 # Slack notifications disabled by default in tests
256 config.SLACK_ENABLED = False
257 config.SLACK_BOT_TOKEN = ""
258 config.SLACK_DONATIONS_CHANNEL = ""
259 config.SLACK_MERCH_CHANNEL = ""
261 # Profiling disabled by default in tests
262 config.PYROSCOPE_ENABLED = False
263 config.PYROSCOPE_SERVER = "https://localhost"
264 config.PYROSCOPE_AUTH_TOKEN = "token"
266 yield None
268 config.copy_from(prevconfig)
269 experimentation._initialized = prev_initialized
270 experimentation._load_local_flags = prev_load_local_flags
273class Flags:
274 """Test handle for setting feature flag values in file-override mode; see the `flags` fixture."""
276 def __init__(self, values: dict[str, Any]) -> None:
277 self._values = values
279 def set_boolean(self, key: str, value: bool) -> None:
280 self._values[key] = value
282 def set_string(self, key: str, value: str) -> None:
283 self._values[key] = value
285 def set_integer(self, key: str, value: int) -> None:
286 self._values[key] = value
288 def set_float(self, key: str, value: float) -> None:
289 self._values[key] = value
291 def set_object(self, key: str, value: Any) -> None:
292 self._values[key] = value
295@pytest.fixture
296def flags(monkeypatch) -> Flags:
297 """
298 Override feature flag values for a test (file-override mode).
300 Starts from the test defaults (production gates on), so a test flips individual flags:
302 def test_x(flags):
303 flags.set_boolean("test_growthbook_integration", False)
304 """
305 values = dict(_TEST_FLAG_DEFAULTS)
306 monkeypatch.setattr(experimentation, "_load_local_flags", lambda _path: values)
307 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", "feature-flags.dev.json")
308 return Flags(values)
311class FeatureFlags:
312 """Test handle for controlling feature flag values; see the `feature_flags` fixture."""
314 def __init__(self, features: dict[str, Any]) -> None:
315 self._features = features
317 def set(self, key: str, value: Any) -> None:
318 """Make `key` resolve to `value` for every user (logged in or anonymous)."""
319 self._features[key] = {"defaultValue": value}
321 def set_definition(self, key: str, definition: dict[str, Any]) -> None:
322 """Set a raw GrowthBook feature definition, for exercising rollouts/experiments."""
323 self._features[key] = definition
326@pytest.fixture
327def feature_flags(monkeypatch) -> FeatureFlags:
328 """
329 Enable GrowthBook-mode flag evaluation against an in-memory snapshot; tests set values by key.
331 Usage:
332 def test_x(db, feature_flags):
333 feature_flags.set("my_flag", True)
334 ...
335 """
336 features: dict[str, Any] = {}
337 monkeypatch.setattr(experimentation, "_initialized", True)
338 monkeypatch.setattr(experimentation, "_state", {"features": features, "savedGroups": {}})
339 # Switch to GrowthBook mode (empty override path).
340 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", "")
341 return FeatureFlags(features)
344@pytest.fixture
345def fast_passwords():
346 # password hashing, by design, takes a lot of time, which slows down the tests.
347 # here we jump through some hoops to make this fast by removing the hashing step
349 def fast_hash(password: bytes) -> bytes:
350 return b"fake hash:" + password
352 def fast_verify(hashed: bytes, password: bytes) -> bool:
353 return hashed == fast_hash(password)
355 with patch("couchers.crypto.nacl.pwhash.verify", fast_verify):
356 with patch("couchers.crypto.nacl.pwhash.str", fast_hash):
357 yield
360@pytest.fixture
361def email_collector():
362 """Captures emails and allows inspecting them."""
364 with EmailCollector() as collector:
365 yield collector
368@pytest.fixture
369def push_collector():
370 """
371 See test_SendTestPushNotification for an example on how to use this fixture
372 """
373 with PushCollector() as collector:
374 yield collector
377@pytest.fixture
378def moderator():
379 """
380 Creates a moderator (superuser) and provides methods to exercise the moderation API.
382 Usage:
383 def test_example(db, moderator):
384 # ... create a host request ...
385 moderator.approve_host_request(host_request_id)
386 """
387 user, token = generate_user(is_superuser=True)
388 yield Moderator(user, token)