Coverage for app/backend/src/tests/fixtures/timewarp.py: 98%
78 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 collections.abc import Generator
2from datetime import UTC, datetime, timedelta
3from unittest.mock import patch
5from sqlalchemy import Connection, event, text
7from couchers import utils
8from couchers.db import _get_base_engine
10FROZEN_TEST_TIME = datetime(2020, 1, 1, tzinfo=UTC)
13class Clock:
14 """
15 What the `timewarp` and `frozen_timewarp` fixtures below have in common: a clock that python and
16 postgres both read, and that a test moves with advance(timedelta(days=30)), negative to rewind.
18 Whether it ticks is a property of the fixture the test asked for rather than something it can
19 turn on and off partway through, so the two are separate clocks rather than one with a mode.
20 Each puts the clock at an instant under its own name, run_from and freeze_at, so a call site
21 says which kind of clock it's talking to.
22 """
24 def __init__(self) -> None:
25 self._open_transactions: set[Connection] = set()
27 def now(self) -> datetime:
28 raise NotImplementedError
30 def _db_settings(self) -> tuple[str, str]:
31 """What postgres needs to read this same clock, as (mock.offset, mock.frozen_at)."""
32 raise NotImplementedError
34 def _refuse_mid_transaction(self) -> None:
35 if self._open_transactions:
36 raise RuntimeError(
37 "can't move the clock while a transaction is open: postgres reads the clock as it "
38 "was at transaction start, so this would silently do nothing until the next one. "
39 "Move the clock outside the session_scope block."
40 )
43class Timewarp(Clock):
44 """
45 A clock that keeps running, displaced from the real one by an offset. Each side applies that
46 offset to its own clock, so the two stay as (im)perfectly aligned as they normally are, and
47 run_from lands within a hair of the instant asked for rather than exactly on it. Postgres also
48 still reports transaction start time, so a long transaction sees its own start rather than a
49 later advance.
50 """
52 def __init__(self) -> None:
53 super().__init__()
54 self.offset = timedelta()
56 def now(self) -> datetime:
57 return datetime.now(tz=UTC) + self.offset
59 def run_from(self, when: datetime) -> None:
60 """Sets the clock to `when` and lets it tick on from there."""
61 _check_aware(when)
62 self._refuse_mid_transaction()
63 self.offset = when - datetime.now(tz=UTC)
65 def advance(self, delta: timedelta) -> None:
66 """Moves the clock forwards, or backwards if delta is negative."""
67 self._refuse_mid_transaction()
68 self.offset += delta
70 def _db_settings(self) -> tuple[str, str]:
71 return _as_interval(self.offset), ""
74class FrozenTimewarp(Clock):
75 """
76 A clock stopped dead at one instant, so both sides read back exactly that rather than ticking on
77 from there. freeze_at and advance move it to another standstill.
78 """
80 def __init__(self, at: datetime) -> None:
81 super().__init__()
82 _check_aware(at)
83 # utils.now() is UTC-aware everywhere else, and callers do read the tzinfo off it
84 self.frozen_at = at.astimezone(UTC)
86 def now(self) -> datetime:
87 return self.frozen_at
89 def freeze_at(self, when: datetime) -> None:
90 """Stops the clock at `when` instead of wherever it was stopped before."""
91 _check_aware(when)
92 self._refuse_mid_transaction()
93 self.frozen_at = when.astimezone(UTC)
95 def advance(self, delta: timedelta) -> None:
96 """Moves the clock forwards, or backwards if delta is negative."""
97 self._refuse_mid_transaction()
98 self.frozen_at += delta
100 def _db_settings(self) -> tuple[str, str]:
101 return "", self.frozen_at.isoformat()
104def _check_aware(when: datetime) -> None:
105 if when.tzinfo is None:
106 raise ValueError("timewarp needs an aware datetime, this one has no timezone")
109# mock goes ahead of pg_catalog so that an unqualified now() finds mock.now() first
110MOCK_SEARCH_PATH = "public, mock, pg_catalog"
113def create_mock_clock(conn: Connection) -> None:
114 """
115 Installs mock.now(), the postgres half of this fixture: the frozen instant if there is one, and
116 otherwise postgres' own clock displaced by the offset. Both settings are read per transaction,
117 and set by install_timewarp below.
119 This lives outside create_schema_from_models because it has to exist before *either* way of
120 building the schema runs, and survive drop_database() in between, so that migrations and
121 models bake the same function into their column defaults.
122 """
123 conn.execute(
124 text("""
125 CREATE SCHEMA mock;
127 CREATE FUNCTION mock.now() RETURNS timestamptz
128 LANGUAGE sql STABLE AS $$
129 SELECT coalesce(
130 nullif(current_setting('mock.frozen_at', true), '')::timestamptz,
131 pg_catalog.now() + coalesce(
132 nullif(current_setting('mock.offset', true), '')::interval,
133 interval '0'
134 )
135 );
136 $$;
137 """)
138 )
139 conn.commit()
142def mock_clock_installed(conn: Connection) -> bool:
143 """
144 Whether this database has the mock clock, ie whether shifting it does anything at all. In one
145 built without it, an unqualified now() is still pg_catalog.now() and reports the real time.
146 """
147 installed: bool = conn.exec_driver_sql(
148 "SELECT to_regprocedure('now()') IS NOT DISTINCT FROM to_regprocedure('mock.now()')"
149 ).scalar_one()
150 return installed
153def _as_interval(delta: timedelta) -> str:
154 # these three are exact and sum to delta, unlike total_seconds() which is a float
155 return f"{delta.days} days {delta.seconds} seconds {delta.microseconds} microseconds"
158def install_timewarp[WarpT: Clock](warp: WarpT) -> Generator[WarpT]:
159 # the engine is only connected to once something runs a query, so this is fine without the db fixture
160 engine = _get_base_engine()
162 # postgres reads the clock through mock.now(), which returns mock.frozen_at if there is one and
163 # otherwise adds mock.offset to its own clock. Both are set per transaction so they can't leak
164 # into another test via a pooled connection.
165 def sync_db_clock(conn: Connection) -> None:
166 conn.exec_driver_sql(
167 "SELECT set_config('mock.offset', %s, true), set_config('mock.frozen_at', %s, true)",
168 warp._db_settings(),
169 )
170 if not mock_clock_installed(conn):
171 raise RuntimeError(
172 "timewarp can't move the postgres clock in this database: now() still resolves to "
173 "pg_catalog.now(), so the database would quietly report the real time. mock.now() "
174 "is installed when the test database is built, so request the `db` fixture in any "
175 "test that uses timewarp and touches the database."
176 )
177 warp._open_transactions.add(conn)
179 def transaction_ended(conn: Connection) -> None:
180 warp._open_transactions.discard(conn)
182 event.listen(engine, "begin", sync_db_clock)
183 event.listen(engine, "commit", transaction_ended)
184 event.listen(engine, "rollback", transaction_ended)
185 try:
186 with patch.object(utils, "_mockable_now", warp.now):
187 yield warp
188 finally:
189 event.remove(engine, "begin", sync_db_clock)
190 event.remove(engine, "commit", transaction_ended)
191 event.remove(engine, "rollback", transaction_ended)