Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions zeeguu/core/test/frozen_clock.py
Original file line number Diff line number Diff line change
@@ -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)
)
28 changes: 28 additions & 0 deletions zeeguu/core/test/test_friends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions zeeguu/core/util/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)


"""
Expand Down
Loading