Coverage for app/backend/src/tests/conftest.py: 97%
256 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +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"
308 config.COOKIE_SAMESITE = "Strict"
309 config.COOKIE_PARTITIONED = False
311 config.SMS_SENDER_ID = "invalid"
313 config.ENABLE_EMAIL = False
314 config.NOTIFICATION_EMAIL_SENDER = "Couchers.org"
315 config.NOTIFICATION_EMAIL_ADDRESS = "notify@couchers.org.invalid"
316 config.MODERATION_EMAIL_SENDER = "Couchers.org Moderation"
317 config.MODERATION_EMAIL_ADDRESS = "moderation@couchers.org.invalid"
318 config.NOTIFICATION_PREFIX = "[TEST] "
319 config.REPORTS_EMAIL_RECIPIENT = "reports@couchers.org.invalid"
320 config.CONTRIBUTOR_FORM_EMAIL_RECIPIENT = "forms@couchers.org.invalid"
321 config.MODS_EMAIL_RECIPIENT = "mods@couchers.org.invalid"
323 config.STRIPE_API_KEY = ""
324 config.STRIPE_WEBHOOK_SECRET = ""
325 config.STRIPE_RECURRING_PRODUCT_ID = ""
326 config.STRIPE_YEARLY_RECURRING_PRODUCT_ID = ""
328 config.IRIS_ID_PUBKEY = ""
329 config.IRIS_ID_SECRET = ""
330 # corresponds to private key e6c2fbf3756b387bc09a458a7b85935718ef3eb1c2777ef41d335c9f6c0ab272
331 config.VERIFICATION_DATA_PUBLIC_KEY = bytes.fromhex(
332 "dd740a2b2a35bf05041a28257ea439b30f76f056f3698000b71e6470cd82275f"
333 )
335 config.MYPOSTCARD_API_KEY = "test-api-key"
336 config.MYPOSTCARD_USERNAME = "test-username"
337 config.MYPOSTCARD_PASSWORD = "test-password"
338 config.MYPOSTCARD_PRODUCT_CODE = "J9GCU"
339 config.MYPOSTCARD_CAMPAIGN_ID = "295"
340 # Flow tests exercise the posting path with `send_postcard` mocked; tests for the bypass flip this on
341 config.POSTAL_VERIFICATION_BYPASS_POST_AND_EMAIL_CODE_FOR_TESTING = False
343 config.SMTP_HOST = "localhost"
344 config.SMTP_PORT = 587
345 config.SMTP_USERNAME = "username"
346 config.SMTP_PASSWORD = "password"
348 config.ENABLE_MEDIA = True
349 config.MEDIA_SERVER_SECRET_KEY = bytes.fromhex("91e29bbacc74fa7e23c5d5f34cca5015cb896e338a620003de94a502a461f4bc")
350 config.MEDIA_SERVER_BEARER_TOKEN = "c02d383897d3b82774ced09c9e17802164c37e7e105d8927553697bf4550e91e"
351 config.MEDIA_SERVER_BASE_URL = "http://localhost:5001"
352 config.MEDIA_SERVER_UPLOAD_BASE_URL = "http://localhost:5001"
354 config.BUG_TOOL_ENABLED = False
355 config.BUG_TOOL_GITHUB_REPO = "org/repo"
356 config.BUG_TOOL_GITHUB_USERNAME = "user"
357 config.BUG_TOOL_GITHUB_TOKEN = "token"
359 config.SENTRY_FRONTEND_PROJECT_ID = "1234"
361 config.LISTMONK_BASE_URL = "https://localhost"
362 config.LISTMONK_API_USERNAME = "..."
363 config.LISTMONK_API_KEY = "..."
364 config.LISTMONK_LIST_ID = 3
366 config.PUSH_NOTIFICATIONS_ENABLED = True
367 config.PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY = "uI1DCR4G1AdlmMlPfRLemMxrz9f3h4kvjfnI8K9WsVI"
368 config.PUSH_NOTIFICATIONS_VAPID_SUBJECT = "mailto:testing@couchers.org.invalid"
370 config.ACTIVENESS_PROBES_ENABLED = True
372 # File-override mode; gates forced True via the stubbed loader below. Tests needing GrowthBook use `feature_flags`.
373 config.FEATURE_FLAGS_FILE_OVERRIDE_PATH = "feature-flags.dev.json"
374 config.GROWTHBOOK_API_HOST = "https://cdn.growthbook.io"
375 config.GROWTHBOOK_CLIENT_KEY = ""
376 config.GROWTHBOOK_CACHE_PATH = ""
377 experimentation._initialized = True
378 experimentation._load_local_flags = lambda _path: _TEST_FLAG_DEFAULTS # type: ignore[assignment]
380 # Moderation auto-approval deadline - 0 disables, set in tests that need it
381 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0
382 # Bot user ID for automated moderation - will be set to a real user in tests that need it
383 config.MODERATION_BOT_USER_ID = 1
385 # Dev APIs disabled by default in tests
386 config.ENABLE_DEV_APIS = False
388 # Slack notifications disabled by default in tests
389 config.SLACK_ENABLED = False
390 config.SLACK_BOT_TOKEN = ""
391 config.SLACK_DONATIONS_CHANNEL = ""
392 config.SLACK_MERCH_CHANNEL = ""
394 # Profiling disabled by default in tests
395 config.PYROSCOPE_ENABLED = False
396 config.PYROSCOPE_SERVER = "https://localhost"
397 config.PYROSCOPE_AUTH_TOKEN = "token"
399 # No Valkey by default, so rate limiting is disabled; tests that exercise it inject an in-memory store
400 config.VALKEY_HOST = ""
401 config.VALKEY_PORT = 6379
402 config.RATE_LIMIT_IPV6_PREFIX = 64
404 yield None
406 config.copy_from(prevconfig)
407 experimentation._initialized = prev_initialized
408 experimentation._load_local_flags = prev_load_local_flags
411@pytest.fixture(autouse=True)
412def _isolate_config() -> Generator[None]:
413 """
414 `_testconfig` alone isn't enough: being class-scoped, it doesn't restore between tests within a
415 `class Test...`.
416 """
417 prevconfig = config.copy()
418 yield
419 config.copy_from(prevconfig)
422class Flags:
423 """Test handle for setting feature flag values in file-override mode; see the `flags` fixture."""
425 def __init__(self, values: dict[str, Any]) -> None:
426 self._values = values
428 def set_boolean(self, key: str, value: bool) -> None:
429 self._values[key] = value
431 def set_string(self, key: str, value: str) -> None:
432 self._values[key] = value
434 def set_integer(self, key: str, value: int) -> None:
435 self._values[key] = value
437 def set_float(self, key: str, value: float) -> None:
438 self._values[key] = value
440 def set_object(self, key: str, value: Any) -> None:
441 self._values[key] = value
444@pytest.fixture
445def flags(monkeypatch) -> Flags:
446 """
447 Override feature flag values for a test (file-override mode).
449 Starts from the test defaults (production gates on), so a test flips individual flags:
451 def test_x(flags):
452 flags.set_boolean("test_growthbook_integration", False)
453 """
454 values = dict(_TEST_FLAG_DEFAULTS)
455 monkeypatch.setattr(experimentation, "_load_local_flags", lambda _path: values)
456 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", "feature-flags.dev.json")
457 return Flags(values)
460class FeatureFlags:
461 """Test handle for controlling feature flag values; see the `feature_flags` fixture."""
463 def __init__(self, features: dict[str, Any]) -> None:
464 self._features = features
466 def set(self, key: str, value: Any) -> None:
467 """Make `key` resolve to `value` for every user (logged in or anonymous)."""
468 self._features[key] = {"defaultValue": value}
470 def set_definition(self, key: str, definition: dict[str, Any]) -> None:
471 """Set a raw GrowthBook feature definition, for exercising rollouts/experiments."""
472 self._features[key] = definition
475@pytest.fixture
476def feature_flags(monkeypatch) -> FeatureFlags:
477 """
478 Enable GrowthBook-mode flag evaluation against an in-memory snapshot; tests set values by key.
480 Usage:
481 def test_x(db, feature_flags):
482 feature_flags.set("my_flag", True)
483 ...
484 """
485 features: dict[str, Any] = {}
486 monkeypatch.setattr(experimentation, "_initialized", True)
487 monkeypatch.setattr(experimentation, "_state", {"features": features, "savedGroups": {}})
488 # Switch to GrowthBook mode (empty override path).
489 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", "")
490 return FeatureFlags(features)
493@pytest.fixture
494def low_rate_limits(monkeypatch) -> None:
495 """
496 Shrinks every rate limit so a test can walk past it in a handful of calls.
498 The production limits run up to 150 actions, and a test that has to exceed one spends most of its
499 time creating the users to act on. The tests read the limits out of the definitions, so they pick
500 these up without knowing they've been lowered.
501 """
502 for action, definition in list(RATE_LIMIT_DEFINITIONS.items()):
503 monkeypatch.setitem(RATE_LIMIT_DEFINITIONS, action, replace(definition, warning_limit=3, hard_limit=6))
506@pytest.fixture
507def fast_passwords():
508 # password hashing, by design, takes a lot of time, which slows down the tests.
509 # here we jump through some hoops to make this fast by removing the hashing step
511 def fast_hash(password: bytes) -> bytes:
512 return b"fake hash:" + password
514 def fast_verify(hashed: bytes, password: bytes) -> bool:
515 return hashed == fast_hash(password)
517 with patch("couchers.crypto.nacl.pwhash.verify", fast_verify):
518 with patch("couchers.crypto.nacl.pwhash.str", fast_hash):
519 yield
522@pytest.fixture
523def email_collector():
524 """Captures emails and allows inspecting them."""
526 with EmailCollector() as collector:
527 yield collector
530@pytest.fixture
531def push_collector():
532 """
533 See test_SendTestPushNotification for an example on how to use this fixture
534 """
535 with PushCollector() as collector:
536 yield collector
539@pytest.fixture
540def moderator():
541 """
542 Creates a moderator (superuser) and provides methods to exercise the moderation API.
544 Usage:
545 def test_example(db, moderator):
546 # ... create a host request ...
547 moderator.approve_host_request(host_request_id)
548 """
549 user, token = generate_user(is_superuser=True)
550 yield Moderator(user, token)