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

1import os 

2import re 

3from collections.abc import Generator 

4from tempfile import TemporaryDirectory 

5from typing import Any 

6from unittest.mock import patch 

7 

8import pytest 

9from sqlalchemy import Connection, Engine 

10from sqlalchemy.sql import text 

11 

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 

15 

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 ) 

21 

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 

32 

33 

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

42 

43 postgres_dsn = re.sub(r"/testdb$", "/postgres", dsn) 

44 

45 with autocommit_engine(postgres_dsn) as engine: 

46 yield engine 

47 

48 

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 

56 

57 

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 

66 

67 

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 

75 

76 

77# Static tables that should not be truncated between tests 

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

79 

80 

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" 

90 

91 postgres_conn.execute(text("DROP DATABASE IF EXISTS testdb WITH (FORCE)")) 

92 postgres_conn.execute(text("CREATE DATABASE testdb")) 

93 

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 ) 

104 

105 create_schema_from_models(testdb_engine) 

106 populate_testing_resources(conn) 

107 

108 

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

127 

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

132 

133 

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) 

141 

142 

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) 

149 

150 

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} 

167 

168 

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 

174 

175 config.IN_TEST = True 

176 

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" 

184 

185 config.SMS_SENDER_ID = "invalid" 

186 

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" 

194 

195 config.STRIPE_API_KEY = "" 

196 config.STRIPE_WEBHOOK_SECRET = "" 

197 config.STRIPE_RECURRING_PRODUCT_ID = "" 

198 

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 ) 

205 

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" 

211 

212 config.SMTP_HOST = "localhost" 

213 config.SMTP_PORT = 587 

214 config.SMTP_USERNAME = "username" 

215 config.SMTP_PASSWORD = "password" 

216 

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" 

222 

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" 

227 

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

229 config.LISTMONK_API_USERNAME = "..." 

230 config.LISTMONK_API_KEY = "..." 

231 config.LISTMONK_LIST_ID = 3 

232 

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" 

236 

237 config.ACTIVENESS_PROBES_ENABLED = True 

238 

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] 

246 

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 

251 

252 # Dev APIs disabled by default in tests 

253 config.ENABLE_DEV_APIS = False 

254 

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

260 

261 # Profiling disabled by default in tests 

262 config.PYROSCOPE_ENABLED = False 

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

264 config.PYROSCOPE_AUTH_TOKEN = "token" 

265 

266 yield None 

267 

268 config.copy_from(prevconfig) 

269 experimentation._initialized = prev_initialized 

270 experimentation._load_local_flags = prev_load_local_flags 

271 

272 

273class Flags: 

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

275 

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

277 self._values = values 

278 

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

280 self._values[key] = value 

281 

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

283 self._values[key] = value 

284 

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

286 self._values[key] = value 

287 

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

289 self._values[key] = value 

290 

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

292 self._values[key] = value 

293 

294 

295@pytest.fixture 

296def flags(monkeypatch) -> Flags: 

297 """ 

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

299 

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

301 

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) 

309 

310 

311class FeatureFlags: 

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

313 

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

315 self._features = features 

316 

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} 

320 

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 

324 

325 

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. 

330 

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) 

342 

343 

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 

348 

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

350 return b"fake hash:" + password 

351 

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

353 return hashed == fast_hash(password) 

354 

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

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

357 yield 

358 

359 

360@pytest.fixture 

361def email_collector(): 

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

363 

364 with EmailCollector() as collector: 

365 yield collector 

366 

367 

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 

375 

376 

377@pytest.fixture 

378def moderator(): 

379 """ 

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

381 

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)