Coverage for app/backend/src/tests/test_db.py: 93%

204 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-21 21:52 +0000

1import difflib 

2import os 

3import re 

4import subprocess 

5from pathlib import Path 

6from typing import Any 

7 

8import pytest 

9from google.protobuf import empty_pb2 

10from sqlalchemy import select 

11from sqlalchemy.sql import func 

12 

13from couchers.config import config 

14from couchers.constants import VALID_NAME_MAX_LENGTH 

15from couchers.db import _get_base_engine, apply_migrations, get_parent_node_at_location, session_scope 

16from couchers.jobs.handlers import DatabaseInconsistencyError, check_database_consistency 

17from couchers.models import User 

18from couchers.utils import ( 

19 is_valid_email, 

20 is_valid_name, 

21 is_valid_user_id, 

22 is_valid_username, 

23 parse_date, 

24) 

25from tests.fixtures.db import ( 

26 create_schema_from_models, 

27 drop_database, 

28 generate_user, 

29 pg_dump_is_available, 

30 populate_testing_resources, 

31) 

32from tests.test_communities import create_1d_point, get_community_id, testing_communities # noqa 

33 

34 

35def test_is_valid_user_id() -> None: 

36 assert is_valid_user_id("10") 

37 assert not is_valid_user_id("1a") 

38 assert not is_valid_user_id("01") 

39 

40 

41def test_is_valid_email() -> None: 

42 assert is_valid_email("a@b.cc") 

43 assert is_valid_email("te.st+email.valid@a.org.au.xx.yy") 

44 assert is_valid_email("invalid@yahoo.co.uk") 

45 assert is_valid_email("user+tag@example.com") 

46 assert is_valid_email("first.last@example.com") 

47 assert not is_valid_email("invalid@.yahoo.co.uk") 

48 assert not is_valid_email("test email@couchers.org") 

49 assert not is_valid_email(".testemail@couchers.org") 

50 assert not is_valid_email("testemail@couchersorg") 

51 assert not is_valid_email("b@xxb....blabla") 

52 # dot immediately before @ (the original bug) 

53 assert not is_valid_email("user.@example.com") 

54 # consecutive dots in local part 

55 assert not is_valid_email("user..name@example.com") 

56 

57 

58def test_is_valid_username() -> None: 

59 assert is_valid_username("user") 

60 assert is_valid_username("us") 

61 assert is_valid_username("us_er") 

62 assert is_valid_username("us_er1") 

63 assert not is_valid_username("us_") 

64 assert not is_valid_username("u") 

65 assert not is_valid_username("1us") 

66 assert not is_valid_username("User") 

67 

68 

69def test_is_valid_name() -> None: 

70 # Basics 

71 assert is_valid_name("ab") 

72 assert is_valid_name("a b") 

73 assert is_valid_name("Jean-Luc") 

74 

75 # OK punctuation 

76 assert is_valid_name("King K. Rool") 

77 assert is_valid_name("Doe, John") 

78 assert is_valid_name("Alice & Bob") 

79 assert is_valid_name("Alice / Bob") 

80 assert is_valid_name("Alice | Bob") 

81 

82 # Apostrophes and Quotes 

83 assert is_valid_name("O'Connor") 

84 assert is_valid_name("William “Bill” Clinton") 

85 assert is_valid_name("Sha’Nia Jenkins") 

86 

87 # Other scripts 

88 assert is_valid_name("孙悟空") 

89 assert is_valid_name("Combining Diặcritics") 

90 assert is_valid_name("काव्य") # Hindi combining diacritics 

91 assert is_valid_name("Meritxell Col·lell") # Catalan middle dot 

92 assert is_valid_name("レオナルド・ディカプリオ") # Japanese middle dot 

93 assert is_valid_name("Lanaʻi") # Hawaiian ʻokina glottal stop 

94 

95 # invalid: too short / too long 

96 assert not is_valid_name("a") 

97 assert not is_valid_name("a" * (VALID_NAME_MAX_LENGTH + 1)) 

98 # invalid: only whitespace 

99 assert not is_valid_name(" ") 

100 assert not is_valid_name("") 

101 assert not is_valid_name(" ") 

102 assert not is_valid_name(" ") 

103 # invalid: leading/trailing whitespace 

104 assert not is_valid_name(" leading whitespace") 

105 assert not is_valid_name("trailing whitespace ") 

106 assert not is_valid_name(" surrounding whitespace ") 

107 # invalid: disallowed characters 

108 assert not is_valid_name("digits123") 

109 assert not is_valid_name("email@domain.com") 

110 assert not is_valid_name("Frosty the ☃️") 

111 assert not is_valid_name("exclamative!") 

112 assert not is_valid_name("interrogative?") 

113 assert not is_valid_name("under_score") 

114 assert not is_valid_name("(╯‵□′)╯︵┻━┻") 

115 

116 

117def test_parse_date() -> None: 

118 assert parse_date("2020-01-01") is not None 

119 assert parse_date("1900-01-01") is not None 

120 assert parse_date("2099-01-01") is not None 

121 assert not parse_date("2019-02-29") 

122 assert not parse_date("2019-22-01") 

123 assert not parse_date("2020-1-01") 

124 assert not parse_date("20-01-01") 

125 assert not parse_date("01-01-2020") 

126 assert not parse_date("2020/01/01") 

127 

128 

129def test_get_parent_node_at_location(testing_communities): 

130 with session_scope() as session: 

131 w_id = get_community_id(session, "Global") # 0 to 100 

132 c1_id = get_community_id(session, "Country 1") # 0 to 50 

133 c1r1_id = get_community_id(session, "Country 1, Region 1") # 0 to 10 

134 c1r1c1_id = get_community_id(session, "Country 1, Region 1, City 1") # 0 to 5 

135 c1r1c2_id = get_community_id(session, "Country 1, Region 1, City 2") # 7 to 10 

136 c1r2_id = get_community_id(session, "Country 1, Region 2") # 20 to 25 

137 c1r2c1_id = get_community_id(session, "Country 1, Region 2, City 1") # 21 to 23 

138 c2_id = get_community_id(session, "Country 2") # 52 to 100 

139 c2r1_id = get_community_id(session, "Country 2, Region 1") # 52 to 71 

140 c2r1c1_id = get_community_id(session, "Country 2, Region 1, City 1") # 53 to 70 

141 

142 assert get_parent_node_at_location(session, create_1d_point(1)).id == c1r1c1_id # type: ignore[union-attr] 

143 assert get_parent_node_at_location(session, create_1d_point(3)).id == c1r1c1_id # type: ignore[union-attr] 

144 assert get_parent_node_at_location(session, create_1d_point(6)).id == c1r1_id # type: ignore[union-attr] 

145 assert get_parent_node_at_location(session, create_1d_point(8)).id == c1r1c2_id # type: ignore[union-attr] 

146 assert get_parent_node_at_location(session, create_1d_point(15)).id == c1_id # type: ignore[union-attr] 

147 assert get_parent_node_at_location(session, create_1d_point(51)).id == w_id # type: ignore[union-attr] 

148 

149 

150def pg_dump() -> str: 

151 return subprocess.run( 

152 ["pg_dump", "-s", config.DATABASE_CONNECTION_STRING], stdout=subprocess.PIPE, encoding="ascii", check=True 

153 ).stdout 

154 

155 

156def sort_pg_dump_output(output: str) -> str: 

157 """Sorts the tables, functions and indices dumped by pg_dump in 

158 alphabetic order. Also sorts all lists enclosed with parentheses 

159 in alphabetic order. 

160 """ 

161 # Temporary replace newline with another character for easier 

162 # pattern matching. 

163 s = output.replace("\n", "§") 

164 

165 # Parameter lists are enclosed with parentheses and every entry 

166 # ends with a comma last on the line. 

167 s = re.sub(r" \(§(.*?)§\);", lambda m: " (§" + ",§".join(sorted(m.group(1).split(",§"))) + "§);", s) 

168 

169 # The header for all objects (tables, functions, indices, etc.) 

170 # seems to all start with two dashes and a space. We don't care 

171 # which kind of object it is here. 

172 s = "§-- ".join(sorted(s.split("§-- "))) 

173 

174 # Switch our temporary newline replacement to real newline. 

175 return s.replace("§", "\n") 

176 

177 

178def test_sort_pg_dump_output() -> None: 

179 assert sort_pg_dump_output(" (\nb,\nc,\na\n);\n") == " (\na,\nb,\nc\n);\n" 

180 

181 

182def strip_leading_whitespace(lines: list[str]) -> list[str]: 

183 return [s.lstrip() for s in lines] 

184 

185 

186@pytest.fixture 

187def restore_db_after_migration_test(db): 

188 try: 

189 yield 

190 finally: 

191 # Dispose the engine's connection pool since we dropped/recreated PostGIS extension, 

192 # which invalidates cached operator OIDs in existing connections 

193 engine = _get_base_engine() 

194 engine.dispose() 

195 

196 # Restore test resources since we destroyed the database 

197 # This is needed because setup_testdb is session-scoped and won't run again 

198 with engine.connect() as conn: 

199 populate_testing_resources(conn) 

200 conn.commit() 

201 

202 

203@pytest.mark.skipif(not pg_dump_is_available(), reason="Can't run migration tests without pg_dump") 

204def test_migrations(db, testconfig: dict[str, Any], restore_db_after_migration_test) -> None: 

205 """ 

206 Compares the database schema built up from migrations with the 

207 schema built by models.py. Both scenarios are started from an 

208 empty database and dumped with pg_dump. Any unexplainable 

209 differences in the output are reported in unified diff format and 

210 fail the test. 

211 

212 Note: this takes about 2 minutes in CI, because the real timezone_areas.sql file 

213 is used, and it's big. Locally, timezone_areas.sql-fake is used. 

214 """ 

215 drop_database() 

216 # rebuild it with alembic migrations 

217 apply_migrations() 

218 

219 with_migrations = pg_dump() 

220 

221 drop_database() 

222 # create everything from the current models, not incrementally 

223 # through migrations 

224 create_schema_from_models() 

225 

226 from_scratch = pg_dump() 

227 

228 # Save the raw schemas to files for CI artifacts 

229 schema_output_dir = os.environ.get("TEST_SCHEMA_OUTPUT_DIR") 

230 if schema_output_dir: 230 ↛ 236line 230 didn't jump to line 236 because the condition on line 230 was always true

231 output_path = Path(schema_output_dir) 

232 output_path.mkdir(parents=True, exist_ok=True) 

233 (output_path / "schema_from_migrations.sql").write_text(with_migrations) 

234 (output_path / "schema_from_models.sql").write_text(from_scratch) 

235 

236 def message(s: str) -> list[str]: 

237 s = sort_pg_dump_output(s) 

238 

239 # filter out alembic tables 

240 s = "\n-- ".join(x for x in s.split("\n-- ") if not x.startswith("Name: alembic_")) 

241 

242 # filter out \restrict and \unrestrict lines (Postgres 16+) 

243 s = "\n".join( 

244 line for line in s.splitlines() if not line.startswith("\\restrict") and not line.startswith("\\unrestrict") 

245 ) 

246 

247 return strip_leading_whitespace(s.splitlines()) 

248 

249 diff = "\n".join( 

250 difflib.unified_diff(message(with_migrations), message(from_scratch), fromfile="migrations", tofile="model") 

251 ) 

252 print(diff) 

253 success = diff == "" 

254 assert success 

255 

256 

257def test_slugify(db): 

258 with session_scope() as session: 

259 assert session.execute(func.slugify("this is a test")).scalar_one() == "this-is-a-test" 

260 assert session.execute(func.slugify("this is ä test")).scalar_one() == "this-is-a-test" 

261 # nothing here gets converted to ascci by unaccent, so it should be empty 

262 assert session.execute(func.slugify("Создай группу своего города")).scalar_one() == "slug" 

263 assert session.execute(func.slugify("Detta är ett test!")).scalar_one() == "detta-ar-ett-test" 

264 assert session.execute(func.slugify("@#(*$&!@#")).scalar_one() == "slug" 

265 assert ( 

266 session.execute( 

267 func.slugify("This has a lot ‒ at least relatively speaking ‒ of punctuation! :)") 

268 ).scalar_one() 

269 == "this-has-a-lot-at-least-relatively-speaking-of-punctuation" 

270 ) 

271 assert ( 

272 session.execute(func.slugify("Multiple - #@! - non-ascii chars")).scalar_one() == "multiple-non-ascii-chars" 

273 ) 

274 assert session.execute(func.slugify("123")).scalar_one() == "123" 

275 assert ( 

276 session.execute( 

277 func.slugify( 

278 "A sentence that is over 64 chars long and where the last thing would be replaced by a dash" 

279 ) 

280 ).scalar_one() 

281 == "a-sentence-that-is-over-64-chars-long-and-where-the-last-thing" 

282 ) 

283 

284 

285def test_database_consistency_check(db, testconfig: dict[str, Any]) -> None: 

286 """The database consistency check should pass with valid user/gallery setup""" 

287 # Create a few users (which auto-creates their profile galleries) 

288 generate_user() 

289 generate_user() 

290 generate_user() 

291 

292 # This should not raise any exceptions 

293 check_database_consistency(empty_pb2.Empty()) 

294 

295 # Now break consistency by removing a user's profile gallery 

296 with session_scope() as session: 

297 user = session.execute(select(User).where(User.deleted_at.is_(None)).limit(1)).scalar_one() 

298 user.profile_gallery_id = None 

299 

300 # This should now raise an exception 

301 with pytest.raises(DatabaseInconsistencyError): 

302 check_database_consistency(empty_pb2.Empty()) 

303 

304 

305def test_migration_ordinals() -> None: 

306 """ 

307 Validates that all migration files use ordinal revision IDs and form a 

308 linear chain. Each migration NNNN must have: 

309 - revision = "NNNN" 

310 - down_revision = "NNNN-1" (or None for 0001) 

311 - filename starting with NNNN_ 

312 """ 

313 versions_dir = Path(__file__).parent.parent / "couchers" / "migrations" / "versions" 

314 

315 migration_files = sorted(f for f in versions_dir.glob("*.py") if re.match(r"^\d{4}_", f.name)) 

316 assert len(migration_files) > 0, f"No migration files found in {versions_dir}" 

317 

318 errors = [] 

319 prev_ordinal = None 

320 

321 for path in migration_files: 

322 filename_match = re.match(r"^(\d{4})_", path.name) 

323 assert filename_match, f"Migration filename does not start with ordinal: {path.name}" 

324 file_ordinal = filename_match.group(1) 

325 

326 content = path.read_text() 

327 

328 rev_match = re.search(r'^revision\s*=\s*"([^"]+)"', content, re.MULTILINE) 

329 down_match = re.search(r"^down_revision\s*=\s*(None|\"([^\"]+)\")", content, re.MULTILINE) 

330 

331 if not rev_match: 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true

332 errors.append(f"{path.name}: missing 'revision' variable") 

333 continue 

334 if not down_match: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 errors.append(f"{path.name}: missing 'down_revision' variable") 

336 continue 

337 

338 revision = rev_match.group(1) 

339 down_revision = down_match.group(2) # None if down_revision = None 

340 

341 if revision != file_ordinal: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true

342 errors.append(f'{path.name}: revision = "{revision}" does not match filename ordinal "{file_ordinal}"') 

343 

344 if file_ordinal == "0001": 

345 if down_revision is not None: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 errors.append(f'{path.name}: first migration must have down_revision = None, got "{down_revision}"') 

347 else: 

348 expected_down = f"{int(file_ordinal) - 1:04d}" 

349 if down_revision != expected_down: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true

350 errors.append(f'{path.name}: down_revision = "{down_revision}" but expected "{expected_down}"') 

351 

352 # Check for gaps in the sequence 

353 expected_ordinal = f"{int(prev_ordinal) + 1:04d}" if prev_ordinal else "0001" 

354 if file_ordinal != expected_ordinal: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true

355 errors.append(f"{path.name}: expected ordinal {expected_ordinal}, got {file_ordinal} (gap in sequence)") 

356 

357 prev_ordinal = file_ordinal 

358 

359 assert not errors, "Migration ordinal errors:\n" + "\n".join(f" - {e}" for e in errors)