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
« 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
5import pytest
6from sqlalchemy import func, select
7from sqlalchemy.sql import text
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
15LEAP_DAY = datetime(2024, 2, 29, 12, 0, 0, tzinfo=UTC)
17# the clocks keep running while a test does its work, and postgres' differs from ours by a hair
18TOLERANCE = timedelta(seconds=1)
21def assert_close(actual: datetime, expected: datetime) -> None:
22 assert abs(actual - expected) < TOLERANCE, f"{actual} is not within {TOLERANCE} of {expected}"
25def make_job() -> BackgroundJob:
26 return BackgroundJob(job_type="dummy_job", payload=b"")
29def db_now() -> datetime:
30 with session_scope() as session:
31 return session.execute(select(func.now())).scalar_one()
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)
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))
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))
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)
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
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)
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)
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))
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)
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())
119 timewarp.advance(-timedelta(days=1))
120 with session_scope() as session:
121 assert session.execute(select(BackgroundJob).where(BackgroundJob.ready_for_retry)).all() == []
123 timewarp.advance(timedelta(days=2))
124 with session_scope() as session:
125 assert session.execute(select(BackgroundJob).where(BackgroundJob.ready_for_retry)).one()
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)
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))
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)
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)
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)
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))
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
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)
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
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
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)
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)
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
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)
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())
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() == []
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()
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
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)
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))
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))
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)
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
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())
296 session.execute(text("SET LOCAL search_path = public, pg_catalog"))
297 assert not mock_clock_installed(session.connection())
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())
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()
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)
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))
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 == []