Coverage for app/backend/src/tests/conftest.py: 97%
253 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
1import hashlib
2import os
3from collections.abc import Generator
4from dataclasses import replace
5from pathlib import Path
6from tempfile import TemporaryDirectory
7from typing import Any
8from unittest.mock import patch
10import pytest
11from sqlalchemy import Connection, Engine
12from sqlalchemy.sql import text
14# Set up environment variables before any couchers imports (they trigger config loading)
15prometheus_multiproc_dir = TemporaryDirectory()
16os.environ["PROMETHEUS_MULTIPROC_DIR"] = prometheus_multiproc_dir.name
18# Default for running with a database from docker-compose.test.yml.
19DEFAULT_DATABASE_CONNECTION_STRING = (
20 "postgresql://postgres:06b3890acd2c235c41be0bbfe22f1b386a04bf02eedf8c977486355616be2aa1@localhost:6544/testdb"
21)
24def _test_database_name() -> str:
25 """
26 The database this run owns, which it drops and rebuilds at the start of the session.
28 The name comes from where this file sits on disk, so suites running side by side out of
29 different checkouts can share one postgres without destroying each other's database. It's
30 printed in the pytest header. Set TEST_DB_NAME to run two suites out of the same checkout.
31 """
32 if name := os.environ.get("TEST_DB_NAME"): 32 ↛ 33line 32 didn't jump to line 33 because the condition on line 32 was never true
33 return name
34 return "testdb_" + hashlib.blake2b(str(Path(__file__).resolve()).encode(), digest_size=4).hexdigest()
37TEST_DB_NAME = _test_database_name()
39# The environment says which postgres to talk to; the database name within it is always ours, so
40# pointing DATABASE_CONNECTION_STRING at a real database can't get that database dropped.
41_dsn = os.environ.get("DATABASE_CONNECTION_STRING", DEFAULT_DATABASE_CONNECTION_STRING)
42os.environ["DATABASE_CONNECTION_STRING"] = _dsn.rsplit("/", 1)[0] + "/" + TEST_DB_NAME
44from couchers import experimentation # noqa: E402
45from couchers.config import config # noqa: E402
46from couchers.db import _get_base_engine # noqa: E402
47from couchers.models import Base # noqa: E402
48from couchers.rate_limits.definitions import RATE_LIMIT_DEFINITIONS # noqa: E402
49from tests.fixtures import query_log # noqa: E402
50from tests.fixtures.db import ( # noqa: E402
51 autocommit_engine,
52 create_schema_from_models,
53 generate_user,
54 populate_testing_resources,
55)
56from tests.fixtures.misc import EmailCollector, Moderator, PushCollector # noqa: E402
57from tests.fixtures.timewarp import ( # noqa: E402
58 FROZEN_TEST_TIME,
59 MOCK_SEARCH_PATH,
60 FrozenTimewarp,
61 Timewarp,
62 create_mock_clock,
63 install_timewarp,
64)
66QUERY_LOG_DIR = Path(__file__).resolve().parents[2] / "test_artifacts" / "queries"
69def pytest_addoption(parser: pytest.Parser) -> None:
70 parser.addoption(
71 "--query-log",
72 action="store_true",
73 help="record every SQL query, grouped by test and by the RPC that issued it, into test_artifacts/queries",
74 )
77def pytest_configure(config: pytest.Config) -> None:
78 if config.getoption("--query-log"):
79 query_log.enable(_get_base_engine())
82def pytest_report_header() -> str:
83 return f"test database: {TEST_DB_NAME}"
86def pytest_sessionfinish(session: pytest.Session) -> None:
87 if session.config.getoption("--query-log"):
88 print(f"\nquery log written to {query_log.dump(QUERY_LOG_DIR)}")
91@pytest.fixture(autouse=True)
92def _record_queries_for_test(request: pytest.FixtureRequest) -> Generator[None]:
93 """Attributes every query to the running test. Cheap no-op unless --query-log is on."""
94 if not request.config.getoption("--query-log"):
95 yield
96 return
97 query_log.set_current_test(request.node.nodeid)
98 yield
99 query_log.set_current_test(None)
102@pytest.fixture(scope="session")
103def postgres_engine() -> Generator[Engine]:
104 """
105 SQLAlchemy engine connected to "postgres" database.
106 """
107 postgres_dsn = config.DATABASE_CONNECTION_STRING.rsplit("/", 1)[0] + "/postgres"
109 with autocommit_engine(postgres_dsn) as engine:
110 yield engine
113@pytest.fixture(scope="session")
114def postgres_conn(postgres_engine: Engine) -> Generator[Connection]:
115 """
116 Acquiring a connection takes time, so we cache it.
117 """
118 with postgres_engine.connect() as conn:
119 yield conn
122@pytest.fixture(scope="session")
123def testdb_engine() -> Generator[Engine]:
124 """
125 SQLAlchemy engine connected to this run's test database.
126 """
127 dsn = config.DATABASE_CONNECTION_STRING
128 with autocommit_engine(dsn) as engine:
129 yield engine
132@pytest.fixture(scope="session")
133def testdb_conn(testdb_engine: Engine) -> Generator[Connection]:
134 """
135 Connection to the test database for truncating tables between tests.
136 """
137 with testdb_engine.connect() as conn:
138 yield conn
141# Static tables that should not be truncated between tests
142STATIC_TABLES = frozenset({"languages", "timezone_areas", "regions"})
145@pytest.fixture(scope="session")
146def setup_testdb(postgres_conn: Connection, testdb_engine: Engine) -> None:
147 """
148 Creates the test database with all the extensions, tables,
149 and static data (languages, regions, timezones). This is done only once
150 per session. Between tests, we truncate all non-static tables.
151 """
152 # running in non-UTC catches some timezone errors
153 os.environ["TZ"] = "America/New_York"
155 postgres_conn.execute(text(f"DROP DATABASE IF EXISTS {TEST_DB_NAME} WITH (FORCE)"))
156 postgres_conn.execute(text(f"CREATE DATABASE {TEST_DB_NAME}"))
158 # A column DEFAULT resolves now() once, when the column is created, and stores the function
159 # identity forever after; later search_path changes don't reach it. So mock.now() has to
160 # already shadow pg_catalog.now() on every connection before any DDL runs, which means
161 # setting this at the database level here, ahead of the first connect. The mock schema
162 # doesn't exist yet, which postgres tolerates in a search_path.
163 postgres_conn.execute(text(f"ALTER DATABASE {TEST_DB_NAME} SET search_path = {MOCK_SEARCH_PATH}"))
165 with testdb_engine.connect() as conn:
166 conn.execute(
167 text(
168 "CREATE SCHEMA logging;"
169 "CREATE EXTENSION IF NOT EXISTS postgis;"
170 "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
171 "CREATE EXTENSION IF NOT EXISTS btree_gist;"
172 "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
173 )
174 )
175 create_mock_clock(conn)
177 create_schema_from_models(testdb_engine)
178 populate_testing_resources(conn)
181_reset_sql: str | None = None
184def _build_reset_sql(conn: Connection) -> str:
185 """
186 Builds the statement that empties every non-static table and rewinds every sequence they use.
188 TRUNCATE would be the obvious way to do this, but it allocates a fresh relfilenode for each
189 table, index, toast relation and sequence it touches, which here is ~600 files per call and
190 costs the same whether the tables hold a million rows or none. DELETE with the foreign key
191 triggers off reaches the same end state ~35x faster, and the tables are near-empty anyway.
192 """
193 tables = []
194 for name in Base.metadata.tables.keys():
195 if name in STATIC_TABLES:
196 continue
197 schema, _, table = name.rpartition(".")
198 tables.append(f'{schema}."{table}"' if schema else f'"{table}"')
200 sequences = conn.execute(
201 text("""
202 SELECT s.schemaname, s.sequencename, s.start_value, d.refobjid::regclass::text AS owner
203 FROM pg_sequences s
204 JOIN pg_class c ON c.relname = s.sequencename AND c.relnamespace = s.schemaname::regnamespace
205 LEFT JOIN pg_depend d ON d.objid = c.oid AND d.deptype = 'a'
206 WHERE s.schemaname IN ('public', 'logging')
207 """)
208 ).all()
210 # The models have foreign key cycles, so there is no order the tables could be emptied in that
211 # would satisfy the constraints; turning the triggers off sidesteps the ordering entirely.
212 statements = ["SET session_replication_role = replica"]
213 statements += [f"DELETE FROM {table}" for table in tables]
214 statements.append("SET session_replication_role = DEFAULT")
215 # setval to the sequence's own start value is what RESTART IDENTITY would have done, and it also
216 # covers the standalone sequences (communities_seq, moderation_seq) that nothing owns.
217 statements += [
218 f"SELECT setval('{schema}.\"{sequence}\"', {start_value}, false)"
219 for schema, sequence, start_value, owner in sequences
220 if owner not in STATIC_TABLES
221 ]
222 return "; ".join(statements)
225def _reset_non_static_tables(conn: Connection) -> None:
226 """
227 Empties all non-static tables and rewinds their sequences.
228 Static tables (languages, timezone_areas, regions) are preserved.
229 """
230 global _reset_sql
231 if _reset_sql is None:
232 _reset_sql = _build_reset_sql(conn)
233 # One roundtrip: psycopg sends a parameterless statement over the simple query protocol, which
234 # takes the whole semicolon-separated batch.
235 conn.exec_driver_sql(_reset_sql)
238@pytest.fixture
239def db(setup_testdb: None, testdb_conn: Connection) -> None:
240 """
241 Empties all non-static tables before each test.
242 Static tables (languages, timezone_areas, regions) are preserved.
243 """
244 _reset_non_static_tables(testdb_conn)
247@pytest.fixture(scope="class")
248def db_class(setup_testdb: None, testdb_conn: Connection) -> None:
249 """
250 The same as above, but with a different scope. Used in test_communities.py.
251 """
252 _reset_non_static_tables(testdb_conn)
255@pytest.fixture
256def timewarp() -> Generator[Timewarp]:
257 """
258 Lets a test move the clock, which keeps running from wherever it's put; see Timewarp.
260 Works without `db`, for a test that only reads the clock from python: nothing connects until
261 something runs a query.
262 """
263 yield from install_timewarp(Timewarp())
266@pytest.fixture
267def frozen_timewarp() -> Generator[FrozenTimewarp]:
268 """
269 Like `timewarp`, but the clock is stopped dead at 2020-01-01 UTC and stays stopped wherever it's
270 moved to, so both python and postgres read back exactly the instant the test asked for.
271 """
272 yield from install_timewarp(FrozenTimewarp(FROZEN_TEST_TIME))
275# Production gates forced True so tests run as "everything enabled". Used by `_testconfig` and the `flags`
276# fixture; tests flip individual values via `flags`.
277_TEST_FLAG_DEFAULTS: dict[str, Any] = {
278 "test_growthbook_integration": True,
279 "sms_enabled": True,
280 "strong_verification_enabled": True,
281 "log_native_ota_requests": True,
282 "donations_enabled": True,
283 "antibot_enabled": True,
284 "postal_verification_enabled": True,
285 "listmonk_enabled": True,
286 "remove_removed_users_from_mailing_list_enabled": True,
287 "notification_translations_enabled": True,
288 "email_ics_attachments_enabled": True,
289 "public_trips_enabled": True,
290}
293@pytest.fixture(scope="class", autouse=True)
294def _testconfig() -> Generator[None]:
295 prevconfig = config.copy()
296 prev_initialized = experimentation._initialized
297 prev_load_local_flags = experimentation._load_local_flags
299 config.IN_TEST = True
301 config.DEV = True
302 config.SECRET = bytes.fromhex("448697d3886aec65830a1ea1497cdf804981e0c260d2f812cf2787c4ed1a262b")
303 config.VERSION = "testing_version"
304 config.BASE_URL = "http://localhost:3000"
305 config.BACKEND_BASE_URL = "http://localhost:8888"
306 config.CONSOLE_BASE_URL = "http://localhost:8888"
307 config.COOKIE_DOMAIN = "localhost"
309 config.SMS_SENDER_ID = "invalid"
311 config.ENABLE_EMAIL = False
312 config.NOTIFICATION_EMAIL_SENDER = "Couchers.org"
313 config.NOTIFICATION_EMAIL_ADDRESS = "notify@couchers.org.invalid"
314 config.MODERATION_EMAIL_SENDER = "Couchers.org Moderation"
315 config.MODERATION_EMAIL_ADDRESS = "moderation@couchers.org.invalid"
316 config.NOTIFICATION_PREFIX = "[TEST] "
317 config.REPORTS_EMAIL_RECIPIENT = "reports@couchers.org.invalid"
318 config.CONTRIBUTOR_FORM_EMAIL_RECIPIENT = "forms@couchers.org.invalid"
319 config.MODS_EMAIL_RECIPIENT = "mods@couchers.org.invalid"
321 config.STRIPE_API_KEY = ""
322 config.STRIPE_WEBHOOK_SECRET = ""
323 config.STRIPE_RECURRING_PRODUCT_ID = ""
325 config.IRIS_ID_PUBKEY = ""
326 config.IRIS_ID_SECRET = ""
327 # corresponds to private key e6c2fbf3756b387bc09a458a7b85935718ef3eb1c2777ef41d335c9f6c0ab272
328 config.VERIFICATION_DATA_PUBLIC_KEY = bytes.fromhex(
329 "dd740a2b2a35bf05041a28257ea439b30f76f056f3698000b71e6470cd82275f"
330 )
332 config.MYPOSTCARD_API_KEY = "test-api-key"
333 config.MYPOSTCARD_USERNAME = "test-username"
334 config.MYPOSTCARD_PASSWORD = "test-password"
335 config.MYPOSTCARD_PRODUCT_CODE = "J9GCU"
336 config.MYPOSTCARD_CAMPAIGN_ID = "295"
337 # Flow tests exercise the posting path with `send_postcard` mocked; tests for the bypass flip this on
338 config.POSTAL_VERIFICATION_BYPASS_POST_AND_EMAIL_CODE_FOR_TESTING = False
340 config.SMTP_HOST = "localhost"
341 config.SMTP_PORT = 587
342 config.SMTP_USERNAME = "username"
343 config.SMTP_PASSWORD = "password"
345 config.ENABLE_MEDIA = True
346 config.MEDIA_SERVER_SECRET_KEY = bytes.fromhex("91e29bbacc74fa7e23c5d5f34cca5015cb896e338a620003de94a502a461f4bc")
347 config.MEDIA_SERVER_BEARER_TOKEN = "c02d383897d3b82774ced09c9e17802164c37e7e105d8927553697bf4550e91e"
348 config.MEDIA_SERVER_BASE_URL = "http://localhost:5001"
349 config.MEDIA_SERVER_UPLOAD_BASE_URL = "http://localhost:5001"
351 config.BUG_TOOL_ENABLED = False
352 config.BUG_TOOL_GITHUB_REPO = "org/repo"
353 config.BUG_TOOL_GITHUB_USERNAME = "user"
354 config.BUG_TOOL_GITHUB_TOKEN = "token"
356 config.SENTRY_FRONTEND_PROJECT_ID = "1234"
358 config.LISTMONK_BASE_URL = "https://localhost"
359 config.LISTMONK_API_USERNAME = "..."
360 config.LISTMONK_API_KEY = "..."
361 config.LISTMONK_LIST_ID = 3
363 config.PUSH_NOTIFICATIONS_ENABLED = True
364 config.PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY = "uI1DCR4G1AdlmMlPfRLemMxrz9f3h4kvjfnI8K9WsVI"
365 config.PUSH_NOTIFICATIONS_VAPID_SUBJECT = "mailto:testing@couchers.org.invalid"
367 config.ACTIVENESS_PROBES_ENABLED = True
369 # File-override mode; gates forced True via the stubbed loader below. Tests needing GrowthBook use `feature_flags`.
370 config.FEATURE_FLAGS_FILE_OVERRIDE_PATH = "feature-flags.dev.json"
371 config.GROWTHBOOK_API_HOST = "https://cdn.growthbook.io"
372 config.GROWTHBOOK_CLIENT_KEY = ""
373 config.GROWTHBOOK_CACHE_PATH = ""
374 experimentation._initialized = True
375 experimentation._load_local_flags = lambda _path: _TEST_FLAG_DEFAULTS # type: ignore[assignment]
377 # Moderation auto-approval deadline - 0 disables, set in tests that need it
378 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0
379 # Bot user ID for automated moderation - will be set to a real user in tests that need it
380 config.MODERATION_BOT_USER_ID = 1
382 # Dev APIs disabled by default in tests
383 config.ENABLE_DEV_APIS = False
385 # Slack notifications disabled by default in tests
386 config.SLACK_ENABLED = False
387 config.SLACK_BOT_TOKEN = ""
388 config.SLACK_DONATIONS_CHANNEL = ""
389 config.SLACK_MERCH_CHANNEL = ""
391 # Profiling disabled by default in tests
392 config.PYROSCOPE_ENABLED = False
393 config.PYROSCOPE_SERVER = "https://localhost"
394 config.PYROSCOPE_AUTH_TOKEN = "token"
396 # No Valkey by default, so rate limiting is disabled; tests that exercise it inject an in-memory store
397 config.VALKEY_HOST = ""
398 config.VALKEY_PORT = 6379
399 config.RATE_LIMIT_IPV6_PREFIX = 64
401 yield None
403 config.copy_from(prevconfig)
404 experimentation._initialized = prev_initialized
405 experimentation._load_local_flags = prev_load_local_flags
408@pytest.fixture(autouse=True)
409def _isolate_config() -> Generator[None]:
410 """
411 `_testconfig` alone isn't enough: being class-scoped, it doesn't restore between tests within a
412 `class Test...`.
413 """
414 prevconfig = config.copy()
415 yield
416 config.copy_from(prevconfig)
419class Flags:
420 """Test handle for setting feature flag values in file-override mode; see the `flags` fixture."""
422 def __init__(self, values: dict[str, Any]) -> None:
423 self._values = values
425 def set_boolean(self, key: str, value: bool) -> None:
426 self._values[key] = value
428 def set_string(self, key: str, value: str) -> None:
429 self._values[key] = value
431 def set_integer(self, key: str, value: int) -> None:
432 self._values[key] = value
434 def set_float(self, key: str, value: float) -> None:
435 self._values[key] = value
437 def set_object(self, key: str, value: Any) -> None:
438 self._values[key] = value
441@pytest.fixture
442def flags(monkeypatch) -> Flags:
443 """
444 Override feature flag values for a test (file-override mode).
446 Starts from the test defaults (production gates on), so a test flips individual flags:
448 def test_x(flags):
449 flags.set_boolean("test_growthbook_integration", False)
450 """
451 values = dict(_TEST_FLAG_DEFAULTS)
452 monkeypatch.setattr(experimentation, "_load_local_flags", lambda _path: values)
453 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", "feature-flags.dev.json")
454 return Flags(values)
457class FeatureFlags:
458 """Test handle for controlling feature flag values; see the `feature_flags` fixture."""
460 def __init__(self, features: dict[str, Any]) -> None:
461 self._features = features
463 def set(self, key: str, value: Any) -> None:
464 """Make `key` resolve to `value` for every user (logged in or anonymous)."""
465 self._features[key] = {"defaultValue": value}
467 def set_definition(self, key: str, definition: dict[str, Any]) -> None:
468 """Set a raw GrowthBook feature definition, for exercising rollouts/experiments."""
469 self._features[key] = definition
472@pytest.fixture
473def feature_flags(monkeypatch) -> FeatureFlags:
474 """
475 Enable GrowthBook-mode flag evaluation against an in-memory snapshot; tests set values by key.
477 Usage:
478 def test_x(db, feature_flags):
479 feature_flags.set("my_flag", True)
480 ...
481 """
482 features: dict[str, Any] = {}
483 monkeypatch.setattr(experimentation, "_initialized", True)
484 monkeypatch.setattr(experimentation, "_state", {"features": features, "savedGroups": {}})
485 # Switch to GrowthBook mode (empty override path).
486 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", "")
487 return FeatureFlags(features)
490@pytest.fixture
491def low_rate_limits(monkeypatch) -> None:
492 """
493 Shrinks every rate limit so a test can walk past it in a handful of calls.
495 The production limits run up to 150 actions, and a test that has to exceed one spends most of its
496 time creating the users to act on. The tests read the limits out of the definitions, so they pick
497 these up without knowing they've been lowered.
498 """
499 for action, definition in list(RATE_LIMIT_DEFINITIONS.items()):
500 monkeypatch.setitem(RATE_LIMIT_DEFINITIONS, action, replace(definition, warning_limit=3, hard_limit=6))
503@pytest.fixture
504def fast_passwords():
505 # password hashing, by design, takes a lot of time, which slows down the tests.
506 # here we jump through some hoops to make this fast by removing the hashing step
508 def fast_hash(password: bytes) -> bytes:
509 return b"fake hash:" + password
511 def fast_verify(hashed: bytes, password: bytes) -> bool:
512 return hashed == fast_hash(password)
514 with patch("couchers.crypto.nacl.pwhash.verify", fast_verify):
515 with patch("couchers.crypto.nacl.pwhash.str", fast_hash):
516 yield
519@pytest.fixture
520def email_collector():
521 """Captures emails and allows inspecting them."""
523 with EmailCollector() as collector:
524 yield collector
527@pytest.fixture
528def push_collector():
529 """
530 See test_SendTestPushNotification for an example on how to use this fixture
531 """
532 with PushCollector() as collector:
533 yield collector
536@pytest.fixture
537def moderator():
538 """
539 Creates a moderator (superuser) and provides methods to exercise the moderation API.
541 Usage:
542 def test_example(db, moderator):
543 # ... create a host request ...
544 moderator.approve_host_request(host_request_id)
545 """
546 user, token = generate_user(is_superuser=True)
547 yield Moderator(user, token)