From ddbb38177d856a723a1a0773240cfa0ef37e0f6f Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Tue, 15 Sep 2026 16:50:41 +0200 Subject: [PATCH] Give the date code one clock a test can freeze Every "is this today?" question in util/time.py read the wall clock directly, in two separate places. That makes a whole class of behaviour untestable: the streak code's answers depend on the relationship between the server's date and the user's, and a test built from the machine's own clock exercises only whichever relationship happens to hold when it runs. A suite can be green all afternoon and fail after local midnight for reasons that have nothing to do with the change under test -- f162c541 went in for exactly that. server_now() and user_local_today() now both derive from one _now_utc(), so a test can pin an instant and move them together. frozen_clock.clock_at does the pinning; the added friend-streak test names two UTC moments that straddle an Auckland midnight while the server stays on the previous day, and asserts the streak comes out the same at both. No behaviour change: datetime.now(TZ) and datetime.now(utc).astimezone(TZ) are the same reading. get_server_time_utc() is left alone -- it documents itself as unused. Co-Authored-By: Claude Opus 5 --- zeeguu/core/test/frozen_clock.py | 40 ++++++++++++++++++++++++++++++++ zeeguu/core/test/test_friends.py | 28 ++++++++++++++++++++++ zeeguu/core/util/time.py | 17 ++++++++++++-- 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 zeeguu/core/test/frozen_clock.py diff --git a/zeeguu/core/test/frozen_clock.py b/zeeguu/core/test/frozen_clock.py new file mode 100644 index 000000000..ed13b5810 --- /dev/null +++ b/zeeguu/core/test/frozen_clock.py @@ -0,0 +1,40 @@ +""" +Freeze the clock that every date computation in zeeguu.core.util.time reads. + +Streak code answers questions like "did they practice *today*?", and the answer +depends on which timezone "today" is asked in. Tests that build their fixtures +from the wall clock of the machine running them only exercise whatever +UTC-vs-local relationship happens to hold at that moment -- which is why a suite +can be green all afternoon and fail after local midnight. Freezing the clock +lets a test name the instant it is testing, so both sides of a midnight are +covered on every run. +""" + +from contextlib import contextmanager +from datetime import datetime, timezone +from unittest.mock import patch + + +@contextmanager +def clock_at(utc_moment: str): + """ + Pretend "now" is `utc_moment`, given as "YYYY-MM-DD HH:MM" in UTC. + + Patches the one function the module derives server_now() and + user_local_today() from, so both move together. + """ + frozen = datetime.fromisoformat(utc_moment).replace(tzinfo=timezone.utc) + with patch("zeeguu.core.util.time._now_utc", return_value=frozen): + yield frozen + + +def server_time_at(utc_moment: str) -> datetime: + """The naive SERVER_TZ value the DB would hold for `utc_moment` (UTC).""" + from zeeguu.core.util.time import SERVER_TZ + + return ( + datetime.fromisoformat(utc_moment) + .replace(tzinfo=timezone.utc) + .astimezone(SERVER_TZ) + .replace(tzinfo=None) + ) diff --git a/zeeguu/core/test/test_friends.py b/zeeguu/core/test/test_friends.py index 9898c98a4..5ba73dbe5 100644 --- a/zeeguu/core/test/test_friends.py +++ b/zeeguu/core/test/test_friends.py @@ -7,6 +7,7 @@ from zeeguu.core.friends.friend_streak import update_streak from zeeguu.core.model.friendship import Friendship from zeeguu.core.model.user_language import UserLanguage +from zeeguu.core.test.frozen_clock import clock_at from zeeguu.core.test.model_test_mixin import ModelTestMixIn from zeeguu.core.test.rules.user_rule import UserRule from zeeguu.core.util.time import SERVER_TZ, server_now, user_local_today, user_zone @@ -53,6 +54,33 @@ def _set_last_practiced(self, user, practiced_at): session.add(user_language) session.commit() + def test_streak_counts_the_same_on_both_sides_of_a_users_midnight(self): + """ + One scenario, two pinned instants: at 06:00 UTC it is still the 1st in + Auckland, at 12:00 UTC it is already the 2nd, while the server is on the + 1st either way. The streak must come out the same. + + Without a frozen clock this test can only ever exercise whichever of the + two relationships happens to hold when the suite runs -- the reason a + green afternoon run could fail after midnight. + """ + self.user.timezone = "Pacific/Auckland" # UTC+13 on this date + session.add(self.user) + session.commit() + + for utc_moment in ["2026-03-01 06:00", "2026-03-01 12:00"]: + with clock_at(utc_moment): + self.friendship.friend_streak = 0 + self.friendship.friend_streak_last_updated = None + self._set_last_practiced(self.user, practiced_days_ago(self.user)) + self._set_last_practiced( + self.friend_user, practiced_days_ago(self.friend_user) + ) + + update_streak(self.friendship, session=session) + + assert self.friendship.friend_streak == 1, f"at {utc_moment} UTC" + def test_update_friend_streak_multiple_friends(self): from zeeguu.core.model.language import Language from zeeguu.core.model.user_language import UserLanguage diff --git a/zeeguu/core/util/time.py b/zeeguu/core/util/time.py index ebbce901e..d4a2f1579 100644 --- a/zeeguu/core/util/time.py +++ b/zeeguu/core/util/time.py @@ -58,8 +58,21 @@ def user_zone(user): return SERVER_TZ +def _now_utc(): + """ + The single clock reading every date computation in this module derives from. + + Everything else here (server_now, user_local_today) is a projection of this + one instant into a different timezone, so a test that patches this function + moves *all* of them together and can pin what happens on either side of a + midnight -- without depending on the wall clock of the machine running it. + See zeeguu/core/test/frozen_clock.py. + """ + return datetime.now(timezone.utc) + + def user_local_today(user): - return datetime.now(user_zone(user)).date() + return _now_utc().astimezone(user_zone(user)).date() def to_user_local_date(user, naive_server_dt): @@ -78,7 +91,7 @@ def server_now(): but it says *which* clock it means, so it stays correct if the constants at the top of this file ever change. """ - return datetime.now(SERVER_TZ).replace(tzinfo=None) + return _now_utc().astimezone(SERVER_TZ).replace(tzinfo=None) """