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

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 

309 config.SMS_SENDER_ID = "invalid" 

310 

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" 

320 

321 config.STRIPE_API_KEY = "" 

322 config.STRIPE_WEBHOOK_SECRET = "" 

323 config.STRIPE_RECURRING_PRODUCT_ID = "" 

324 

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 ) 

331 

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 

339 

340 config.SMTP_HOST = "localhost" 

341 config.SMTP_PORT = 587 

342 config.SMTP_USERNAME = "username" 

343 config.SMTP_PASSWORD = "password" 

344 

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" 

350 

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" 

355 

356 config.SENTRY_FRONTEND_PROJECT_ID = "1234" 

357 

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

359 config.LISTMONK_API_USERNAME = "..." 

360 config.LISTMONK_API_KEY = "..." 

361 config.LISTMONK_LIST_ID = 3 

362 

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" 

366 

367 config.ACTIVENESS_PROBES_ENABLED = True 

368 

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] 

376 

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 

381 

382 # Dev APIs disabled by default in tests 

383 config.ENABLE_DEV_APIS = False 

384 

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

390 

391 # Profiling disabled by default in tests 

392 config.PYROSCOPE_ENABLED = False 

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

394 config.PYROSCOPE_AUTH_TOKEN = "token" 

395 

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 

400 

401 yield None 

402 

403 config.copy_from(prevconfig) 

404 experimentation._initialized = prev_initialized 

405 experimentation._load_local_flags = prev_load_local_flags 

406 

407 

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) 

417 

418 

419class Flags: 

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

421 

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

423 self._values = values 

424 

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

426 self._values[key] = value 

427 

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

429 self._values[key] = value 

430 

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

432 self._values[key] = value 

433 

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

435 self._values[key] = value 

436 

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

438 self._values[key] = value 

439 

440 

441@pytest.fixture 

442def flags(monkeypatch) -> Flags: 

443 """ 

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

445 

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

447 

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) 

455 

456 

457class FeatureFlags: 

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

459 

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

461 self._features = features 

462 

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} 

466 

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 

470 

471 

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. 

476 

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) 

488 

489 

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. 

494 

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

501 

502 

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 

507 

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

509 return b"fake hash:" + password 

510 

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

512 return hashed == fast_hash(password) 

513 

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

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

516 yield 

517 

518 

519@pytest.fixture 

520def email_collector(): 

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

522 

523 with EmailCollector() as collector: 

524 yield collector 

525 

526 

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 

534 

535 

536@pytest.fixture 

537def moderator(): 

538 """ 

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

540 

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)