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) """