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

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 

9 

10import pytest 

11from sqlalchemy import Connection, Engine 

12from sqlalchemy.sql import text 

13 

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 

17 

18# Default for running with a database from docker-compose.test.yml. 

19DEFAULT_DATABASE_CONNECTION_STRING = ( 

20 "postgresql://postgres:06b3890acd2c235c41be0bbfe22f1b386a04bf02eedf8c977486355616be2aa1@localhost:6544/testdb" 

21) 

22 

23 

24def _test_database_name() -> str: 

25 """ 

26 The database this run owns, which it drops and rebuilds at the start of the session. 

27 

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() 

35 

36 

37TEST_DB_NAME = _test_database_name() 

38 

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 

43 

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) 

65 

66QUERY_LOG_DIR = Path(__file__).resolve().parents[2] / "test_artifacts" / "queries" 

67 

68 

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 ) 

75 

76 

77def pytest_configure(config: pytest.Config) -> None: 

78 if config.getoption("--query-log"): 

79 query_log.enable(_get_base_engine()) 

80 

81 

82def pytest_report_header() -> str: 

83 return f"test database: {TEST_DB_NAME}" 

84 

85 

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)}") 

89 

90 

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) 

100 

101 

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" 

108 

109 with autocommit_engine(postgres_dsn) as engine: 

110 yield engine 

111 

112 

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 

120 

121 

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 

130 

131 

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 

139 

140 

141# Static tables that should not be truncated between tests 

142STATIC_TABLES = frozenset({"languages", "timezone_areas", "regions"}) 

143 

144 

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" 

154 

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}")) 

157 

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}")) 

164 

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) 

176 

177 create_schema_from_models(testdb_engine) 

178 populate_testing_resources(conn) 

179 

180 

181_reset_sql: str | None = None 

182 

183 

184def _build_reset_sql(conn: Connection) -> str: 

185 """ 

186 Builds the statement that empties every non-static table and rewinds every sequence they use. 

187 

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}"') 

199 

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() 

209 

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) 

223 

224 

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) 

236 

237 

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) 

245 

246 

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) 

253 

254 

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. 

259 

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()) 

264 

265 

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)) 

273 

274 

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} 

291 

292 

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 

298 

299 config.IN_TEST = True 

300 

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 

310 

311 config.SMS_SENDER_ID = "invalid" 

312 

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" 

322 

323 config.STRIPE_API_KEY = "" 

324 config.STRIPE_WEBHOOK_SECRET = "" 

325 config.STRIPE_RECURRING_PRODUCT_ID = "" 

326 config.STRIPE_YEARLY_RECURRING_PRODUCT_ID = "" 

327 

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 ) 

334 

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 

342 

343 config.SMTP_HOST = "localhost" 

344 config.SMTP_PORT = 587 

345 config.SMTP_USERNAME = "username" 

346 config.SMTP_PASSWORD = "password" 

347 

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" 

353 

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" 

358 

359 config.SENTRY_FRONTEND_PROJECT_ID = "1234" 

360 

361 config.LISTMONK_BASE_URL = "https://localhost" 

362 config.LISTMONK_API_USERNAME = "..." 

363 config.LISTMONK_API_KEY = "..." 

364 config.LISTMONK_LIST_ID = 3 

365 

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" 

369 

370 config.ACTIVENESS_PROBES_ENABLED = True 

371 

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] 

379 

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 

384 

385 # Dev APIs disabled by default in tests 

386 config.ENABLE_DEV_APIS = False 

387 

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 = "" 

393 

394 # Profiling disabled by default in tests 

395 config.PYROSCOPE_ENABLED = False 

396 config.PYROSCOPE_SERVER = "https://localhost" 

397 config.PYROSCOPE_AUTH_TOKEN = "token" 

398 

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 

403 

404 yield None 

405 

406 config.copy_from(prevconfig) 

407 experimentation._initialized = prev_initialized 

408 experimentation._load_local_flags = prev_load_local_flags 

409 

410 

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) 

420 

421 

422class Flags: 

423 """Test handle for setting feature flag values in file-override mode; see the `flags` fixture.""" 

424 

425 def __init__(self, values: dict[str, Any]) -> None: 

426 self._values = values 

427 

428 def set_boolean(self, key: str, value: bool) -> None: 

429 self._values[key] = value 

430 

431 def set_string(self, key: str, value: str) -> None: 

432 self._values[key] = value 

433 

434 def set_integer(self, key: str, value: int) -> None: 

435 self._values[key] = value 

436 

437 def set_float(self, key: str, value: float) -> None: 

438 self._values[key] = value 

439 

440 def set_object(self, key: str, value: Any) -> None: 

441 self._values[key] = value 

442 

443 

444@pytest.fixture 

445def flags(monkeypatch) -> Flags: 

446 """ 

447 Override feature flag values for a test (file-override mode). 

448 

449 Starts from the test defaults (production gates on), so a test flips individual flags: 

450 

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) 

458 

459 

460class FeatureFlags: 

461 """Test handle for controlling feature flag values; see the `feature_flags` fixture.""" 

462 

463 def __init__(self, features: dict[str, Any]) -> None: 

464 self._features = features 

465 

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} 

469 

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 

473 

474 

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. 

479 

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) 

491 

492 

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. 

497 

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)) 

504 

505 

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 

510 

511 def fast_hash(password: bytes) -> bytes: 

512 return b"fake hash:" + password 

513 

514 def fast_verify(hashed: bytes, password: bytes) -> bool: 

515 return hashed == fast_hash(password) 

516 

517 with patch("couchers.crypto.nacl.pwhash.verify", fast_verify): 

518 with patch("couchers.crypto.nacl.pwhash.str", fast_hash): 

519 yield 

520 

521 

522@pytest.fixture 

523def email_collector(): 

524 """Captures emails and allows inspecting them.""" 

525 

526 with EmailCollector() as collector: 

527 yield collector 

528 

529 

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 

537 

538 

539@pytest.fixture 

540def moderator(): 

541 """ 

542 Creates a moderator (superuser) and provides methods to exercise the moderation API. 

543 

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)