Coverage for app/backend/src/tests/test_timewarp.py: 100%

223 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-10 04:35 +0000

1from datetime import UTC, date, datetime, timedelta 

2from unittest.mock import patch 

3from zoneinfo import ZoneInfo 

4 

5import pytest 

6from sqlalchemy import func, select 

7from sqlalchemy.sql import text 

8 

9import tests.fixtures.timewarp 

10from couchers.db import session_scope 

11from couchers.models import BackgroundJob 

12from couchers.utils import now, now_in_timezone, today 

13from tests.fixtures.timewarp import FROZEN_TEST_TIME, FrozenTimewarp, Timewarp, mock_clock_installed 

14 

15LEAP_DAY = datetime(2024, 2, 29, 12, 0, 0, tzinfo=UTC) 

16 

17# the clocks keep running while a test does its work, and postgres' differs from ours by a hair 

18TOLERANCE = timedelta(seconds=1) 

19 

20 

21def assert_close(actual: datetime, expected: datetime) -> None: 

22 assert abs(actual - expected) < TOLERANCE, f"{actual} is not within {TOLERANCE} of {expected}" 

23 

24 

25def make_job() -> BackgroundJob: 

26 return BackgroundJob(job_type="dummy_job", payload=b"") 

27 

28 

29def db_now() -> datetime: 

30 with session_scope() as session: 

31 return session.execute(select(func.now())).scalar_one() 

32 

33 

34def test_run_from_moves_both_clocks(db, timewarp: Timewarp) -> None: 

35 timewarp.run_from(LEAP_DAY) 

36 assert_close(now(), LEAP_DAY) 

37 assert_close(db_now(), LEAP_DAY) 

38 

39 

40def test_advance_moves_both_clocks(db, timewarp: Timewarp) -> None: 

41 timewarp.run_from(LEAP_DAY) 

42 timewarp.advance(timedelta(days=30)) 

43 assert_close(now(), LEAP_DAY + timedelta(days=30)) 

44 assert_close(db_now(), LEAP_DAY + timedelta(days=30)) 

45 

46 

47def test_advance_rewinds(db, timewarp: Timewarp) -> None: 

48 timewarp.run_from(LEAP_DAY) 

49 timewarp.advance(-timedelta(hours=6)) 

50 assert_close(now(), LEAP_DAY - timedelta(hours=6)) 

51 assert_close(db_now(), LEAP_DAY - timedelta(hours=6)) 

52 

53 

54def test_advance_without_setting_a_time_shifts_the_real_clock(db, timewarp: Timewarp) -> None: 

55 before = datetime.now(tz=UTC) 

56 timewarp.advance(timedelta(days=365)) 

57 assert before + timedelta(days=365) <= now() <= datetime.now(tz=UTC) + timedelta(days=365) 

58 

59 

60def test_the_clocks_keep_running(db, timewarp: Timewarp) -> None: 

61 timewarp.run_from(LEAP_DAY) 

62 first, first_db = now(), db_now() 

63 assert now() > first 

64 assert db_now() > first_db 

65 

66 

67def test_server_default_respects_the_warp(db, timewarp: Timewarp) -> None: 

68 timewarp.run_from(LEAP_DAY) 

69 with session_scope() as session: 

70 job = make_job() 

71 session.add(job) 

72 session.flush() 

73 assert_close(job.queued, LEAP_DAY) 

74 assert_close(job.next_attempt_after, LEAP_DAY) 

75 

76 

77def test_warp_persists_across_transactions(db, timewarp: Timewarp) -> None: 

78 """The setting is per-transaction, so each new session has to pick the offset up again.""" 

79 timewarp.run_from(LEAP_DAY) 

80 with session_scope() as session: 

81 session.add(make_job()) 

82 with session_scope() as session: 

83 assert_close(session.execute(select(BackgroundJob)).scalar_one().queued, LEAP_DAY) 

84 

85 

86def test_rows_inserted_at_different_times_differ(db, timewarp: Timewarp) -> None: 

87 timewarp.run_from(LEAP_DAY) 

88 with session_scope() as session: 

89 session.add(make_job()) 

90 timewarp.advance(timedelta(days=1)) 

91 with session_scope() as session: 

92 session.add(make_job()) 

93 with session_scope() as session: 

94 first, second = session.execute(select(BackgroundJob.queued).order_by(BackgroundJob.queued)).scalars().all() 

95 assert_close(first, LEAP_DAY) 

96 assert_close(second, LEAP_DAY + timedelta(days=1)) 

97 

98 

99def test_two_inserts_in_one_transaction_agree(db, timewarp: Timewarp) -> None: 

100 """Postgres reports transaction start time, so the clock running doesn't split these apart.""" 

101 timewarp.run_from(LEAP_DAY) 

102 with session_scope() as session: 

103 first, second = make_job(), make_job() 

104 session.add(first) 

105 session.flush() 

106 session.add(second) 

107 session.flush() 

108 assert first.queued == second.queued 

109 assert_close(first.queued, LEAP_DAY) 

110 

111 

112def test_query_time_now_is_warped(db, timewarp: Timewarp) -> None: 

113 """Unqualified now() in a WHERE clause resolves through search_path too, so hybrid properties 

114 like BackgroundJob.ready_for_retry see the warped clock.""" 

115 timewarp.run_from(LEAP_DAY) 

116 with session_scope() as session: 

117 session.add(make_job()) 

118 

119 timewarp.advance(-timedelta(days=1)) 

120 with session_scope() as session: 

121 assert session.execute(select(BackgroundJob).where(BackgroundJob.ready_for_retry)).all() == [] 

122 

123 timewarp.advance(timedelta(days=2)) 

124 with session_scope() as session: 

125 assert session.execute(select(BackgroundJob).where(BackgroundJob.ready_for_retry)).one() 

126 

127 

128def test_derived_python_helpers_follow(db, timewarp: Timewarp) -> None: 

129 timewarp.run_from(LEAP_DAY) 

130 assert today() == LEAP_DAY.date() 

131 assert_close(now_in_timezone("America/New_York"), LEAP_DAY) 

132 

133 

134def test_moving_the_clock_mid_transaction_raises(db, timewarp: Timewarp) -> None: 

135 with pytest.raises(RuntimeError, match="while a transaction is open"): 

136 with session_scope() as session: 

137 session.add(make_job()) 

138 session.flush() 

139 timewarp.advance(timedelta(days=1)) 

140 

141 

142def test_no_complaint_before_the_transaction_touches_the_database(db, timewarp: Timewarp) -> None: 

143 """A session that hasn't emitted any SQL yet has no transaction, so postgres hasn't pinned 

144 now() and the clock is still free to move.""" 

145 with session_scope() as session: 

146 session.add(make_job()) 

147 timewarp.run_from(LEAP_DAY) 

148 session.flush() 

149 assert_close(session.execute(select(BackgroundJob)).scalar_one().queued, LEAP_DAY) 

150 

151 

152def test_the_clock_is_movable_again_after_the_transaction(db, timewarp: Timewarp) -> None: 

153 with session_scope() as session: 

154 session.add(make_job()) 

155 timewarp.run_from(LEAP_DAY) 

156 assert_close(db_now(), LEAP_DAY) 

157 

158 

159def test_a_rolled_back_transaction_doesnt_wedge_the_clock(db, timewarp: Timewarp) -> None: 

160 with pytest.raises(ZeroDivisionError): 

161 with session_scope() as session: 

162 session.add(make_job()) 

163 raise ZeroDivisionError 

164 timewarp.run_from(LEAP_DAY) 

165 assert_close(db_now(), LEAP_DAY) 

166 

167 

168def test_naive_datetime_rejected(timewarp: Timewarp) -> None: 

169 with pytest.raises(ValueError, match="aware datetime"): 

170 timewarp.run_from(datetime(2024, 2, 29, 12, 0, 0)) 

171 

172 

173def test_both_clocks_start_at_the_frozen_test_time(db, frozen_timewarp: FrozenTimewarp) -> None: 

174 assert now() == datetime(2020, 1, 1, tzinfo=UTC) == FROZEN_TEST_TIME 

175 assert now().tzinfo is UTC 

176 assert db_now() == FROZEN_TEST_TIME 

177 assert now_in_timezone("Australia/Brisbane").hour == 10 

178 

179 

180def test_a_clock_frozen_at_the_test_time_still_moves(db, frozen_timewarp: FrozenTimewarp) -> None: 

181 frozen_timewarp.advance(timedelta(days=1)) 

182 assert now() == FROZEN_TEST_TIME + timedelta(days=1) 

183 assert db_now() == FROZEN_TEST_TIME + timedelta(days=1) 

184 

185 

186def test_freeze_at_moves_both_frozen_clocks(db, frozen_timewarp: FrozenTimewarp) -> None: 

187 frozen_timewarp.freeze_at(LEAP_DAY) 

188 assert now() == LEAP_DAY 

189 assert db_now() == LEAP_DAY 

190 

191 

192def test_a_frozen_clock_doesnt_tick(db, frozen_timewarp: FrozenTimewarp) -> None: 

193 frozen_timewarp.freeze_at(LEAP_DAY) 

194 assert now() == now() == LEAP_DAY 

195 assert db_now() == db_now() == LEAP_DAY 

196 

197 

198def test_advance_while_frozen_stays_frozen(db, frozen_timewarp: FrozenTimewarp) -> None: 

199 frozen_timewarp.freeze_at(LEAP_DAY) 

200 frozen_timewarp.advance(timedelta(days=30)) 

201 assert now() == LEAP_DAY + timedelta(days=30) 

202 assert db_now() == LEAP_DAY + timedelta(days=30) 

203 

204 

205def test_advance_while_frozen_rewinds(db, frozen_timewarp: FrozenTimewarp) -> None: 

206 frozen_timewarp.freeze_at(LEAP_DAY) 

207 frozen_timewarp.advance(-timedelta(hours=6)) 

208 assert now() == LEAP_DAY - timedelta(hours=6) 

209 assert db_now() == LEAP_DAY - timedelta(hours=6) 

210 

211 

212def test_server_default_is_frozen(db, frozen_timewarp: FrozenTimewarp) -> None: 

213 frozen_timewarp.freeze_at(LEAP_DAY) 

214 with session_scope() as session: 

215 job = make_job() 

216 session.add(job) 

217 session.flush() 

218 assert job.queued == LEAP_DAY 

219 

220 

221def test_frozen_rows_inserted_at_different_times_differ(db, frozen_timewarp: FrozenTimewarp) -> None: 

222 frozen_timewarp.freeze_at(LEAP_DAY) 

223 with session_scope() as session: 

224 session.add(make_job()) 

225 frozen_timewarp.advance(timedelta(days=1)) 

226 with session_scope() as session: 

227 session.add(make_job()) 

228 with session_scope() as session: 

229 first, second = session.execute(select(BackgroundJob.queued).order_by(BackgroundJob.queued)).scalars().all() 

230 assert first == LEAP_DAY 

231 assert second == LEAP_DAY + timedelta(days=1) 

232 

233 

234def test_query_time_now_is_frozen(db, frozen_timewarp: FrozenTimewarp) -> None: 

235 frozen_timewarp.freeze_at(LEAP_DAY) 

236 with session_scope() as session: 

237 session.add(make_job()) 

238 

239 frozen_timewarp.advance(-timedelta(days=1)) 

240 with session_scope() as session: 

241 assert session.execute(select(BackgroundJob).where(BackgroundJob.ready_for_retry)).all() == [] 

242 

243 frozen_timewarp.advance(timedelta(days=2)) 

244 with session_scope() as session: 

245 assert session.execute(select(BackgroundJob).where(BackgroundJob.ready_for_retry)).one() 

246 

247 

248def test_derived_python_helpers_follow_a_frozen_clock(db, frozen_timewarp: FrozenTimewarp) -> None: 

249 frozen_timewarp.freeze_at(LEAP_DAY) 

250 assert today() == LEAP_DAY.date() 

251 assert now_in_timezone("America/New_York") == LEAP_DAY 

252 

253 

254def test_setting_a_frozen_clock_mid_transaction_raises(db, frozen_timewarp: FrozenTimewarp) -> None: 

255 with pytest.raises(RuntimeError, match="while a transaction is open"): 

256 with session_scope() as session: 

257 session.add(make_job()) 

258 session.flush() 

259 frozen_timewarp.freeze_at(LEAP_DAY) 

260 

261 

262def test_moving_a_frozen_clock_mid_transaction_raises(db, frozen_timewarp: FrozenTimewarp) -> None: 

263 frozen_timewarp.freeze_at(LEAP_DAY) 

264 with pytest.raises(RuntimeError, match="while a transaction is open"): 

265 with session_scope() as session: 

266 session.add(make_job()) 

267 session.flush() 

268 frozen_timewarp.advance(timedelta(days=1)) 

269 

270 

271def test_a_frozen_clock_rejects_naive_datetimes(frozen_timewarp: FrozenTimewarp) -> None: 

272 with pytest.raises(ValueError, match="aware datetime"): 

273 frozen_timewarp.freeze_at(datetime(2024, 2, 29, 12, 0, 0)) 

274 

275 

276def test_a_frozen_clock_reads_back_in_utc(db, frozen_timewarp: FrozenTimewarp) -> None: 

277 """Like the real utils.now(), whatever timezone the instant was handed over in.""" 

278 frozen_timewarp.freeze_at(datetime(2024, 2, 29, 12, tzinfo=ZoneInfo("Australia/Brisbane"))) 

279 assert now().tzinfo is UTC 

280 assert now() == datetime(2024, 2, 29, 2, tzinfo=UTC) 

281 assert today() == date(2024, 2, 29) 

282 

283 

284def test_the_clock_moves_without_the_db_fixture(frozen_timewarp: FrozenTimewarp) -> None: 

285 """A test that only reads the clock from python doesn't need the database built for it.""" 

286 frozen_timewarp.freeze_at(LEAP_DAY) 

287 assert now() == LEAP_DAY 

288 

289 

290def test_a_database_without_the_mock_clock_is_noticed(db) -> None: 

291 """What the `begin` listener checks before letting a warped test talk to the database, since an 

292 unwarped now() would silently report the real time.""" 

293 with session_scope() as session: 

294 assert mock_clock_installed(session.connection()) 

295 

296 session.execute(text("SET LOCAL search_path = public, pg_catalog")) 

297 assert not mock_clock_installed(session.connection()) 

298 

299 # mock.now() exists but doesn't shadow anything, so unqualified now() is still the real one 

300 session.execute(text("SET LOCAL search_path = pg_catalog, mock")) 

301 assert not mock_clock_installed(session.connection()) 

302 

303 

304def test_touching_a_database_without_the_mock_clock_raises(db, timewarp: Timewarp) -> None: 

305 with patch.object(tests.fixtures.timewarp, "mock_clock_installed", lambda conn: False): 

306 with pytest.raises(RuntimeError, match="can't move the postgres clock"): 

307 db_now() 

308 

309 # the transaction that never got off the ground isn't left counting as open 

310 timewarp.run_from(LEAP_DAY) 

311 assert_close(db_now(), LEAP_DAY) 

312 

313 

314def test_clock_is_real_without_the_fixture(db) -> None: 

315 before = datetime.now(tz=UTC) 

316 assert before <= now() <= datetime.now(tz=UTC) 

317 assert_close(db_now(), datetime.now(tz=UTC)) 

318 

319 

320def test_nothing_depends_on_the_real_now(db) -> None: 

321 """Every column default should have bound mock.now(), not pg_catalog.now(). A dependency on the 

322 real function means some DDL resolved before the search_path was in place.""" 

323 with session_scope() as session: 

324 dependants = ( 

325 session.execute( 

326 text(""" 

327 SELECT pg_describe_object(classid, objid, objsubid) 

328 FROM pg_depend 

329 WHERE refclassid = 'pg_proc'::regclass 

330 AND refobjid = 'pg_catalog.now()'::regprocedure 

331 """) 

332 ) 

333 .scalars() 

334 .all() 

335 ) 

336 assert dependants == []