Skip to content

Commit 9ed228f

Browse files
rerowepclaude
andcommitted
fix(circulation): prevent test failures after 23:00 UTC
Tests comparing loan due-dates against datetime.now() would fail when run after 23:00 UTC because the loan creation and the assertion could straddle midnight CET (UTC+1/+2) and land on different calendar days. - libraries/api.py: always convert input to library local timezone before checking day name and opening times in is_open() - test_loans_rest.py: freeze time around checkout + assertion so both calls see the same instant - test_actions_extend.py: freeze time around extend_loan() + assertion for the same reason; use timezone-aware datetime.now(timezone.utc) - test_extend_external.py: mirror the next_open() logic used by get_extension_params() so the expected date is computed the same way Co-Authored-by: Peter Weber <peter.weber@rero.ch> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 85f98ce commit 9ed228f

File tree

5 files changed

+92
-54
lines changed

5 files changed

+92
-54
lines changed

rero_ils/modules/libraries/api.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,14 @@ def _get_exceptions_matching_date(self, date_to_check, day_only=False):
223223
# date_to_check) and get only the last one.
224224
if exception.get("repeat"):
225225
period = exception["repeat"]["period"].upper()
226+
# Use end-of-day as the rrule upper bound. date_to_check may
227+
# be in a local timezone (e.g. CEST) whose midnight is earlier
228+
# than midnight UTC, which would cause the rrule to miss a
229+
# recurrence that falls on the same calendar day in UTC.
230+
until = date_to_check.replace(hour=23, minute=59, second=59, microsecond=999999)
226231
exception_dates = rrule(
227232
freq=FREQNAMES.index(period),
228-
until=date_to_check,
233+
until=until,
229234
interval=exception["repeat"]["interval"],
230235
dtstart=start_date,
231236
)
@@ -256,6 +261,18 @@ def is_open(self, date=None, day_only=False):
256261
date = date_string_to_utc(date)
257262
if isinstance(date, datetime) and date.tzinfo is None:
258263
date = date.replace(tzinfo=pytz.utc)
264+
# When the caller passes midnight (00:00:00), it signals a "date-only"
265+
# query — they want to know whether the library is open at all that day,
266+
# not at a specific time. _is_betweentimes() relies on midnight to
267+
# trigger exactly this logic, so we must preserve the sentinel after the
268+
# timezone conversion below.
269+
date_only_query = date.hour == date.minute == date.second == 0
270+
# Always evaluate in library local time so day names and opening hours
271+
# are compared in the correct timezone regardless of what tz the caller
272+
# passed in (UTC, local, etc.).
273+
date = date.astimezone(self.get_timezone())
274+
if date_only_query:
275+
date = date.replace(hour=0, minute=0, second=0, microsecond=0)
259276

260277
# STEP 1 :: check about regular rules
261278
# Each library could define if a specific weekday is open or closed.

tests/api/loans/test_loans_rest.py

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import ciso8601
2424
import pytz
2525
from flask import url_for
26+
from freezegun import freeze_time
2627
from invenio_accounts.testutils import login_user_via_session
2728
from invenio_circulation.api import get_loan_for_item
2829

@@ -583,41 +584,50 @@ def test_timezone_due_date(
583584
# Login to perform action
584585
login_user_via_session(client, librarian_martigny.user)
585586

586-
# Checkout the item
587-
res, data = postdata(
588-
client,
589-
"api_item.checkout",
590-
{
591-
"item_pid": item_pid,
592-
"patron_pid": patron_pid,
593-
"transaction_location_pid": loc_public_martigny.pid,
594-
"transaction_user_pid": librarian_martigny.pid,
595-
},
596-
)
597-
assert res.status_code == 200
598-
599-
# Get Loan date (should be in UTC)
600-
loan_pid = data.get("action_applied")[LoanAction.CHECKOUT].get("pid")
601-
loan = Loan.get_record_by_pid(loan_pid)
602-
loan_end_date = loan.get("end_date")
603-
604-
# Get next library open date (should be next monday after X-1 days) where
605-
# X is checkout_duration
606-
soon = datetime.now(pytz.utc) + timedelta(days=(checkout_duration - 1))
607-
lib = Library.get_record_by_pid(item.library_pid)
608-
lib_datetime = lib.next_open(soon)
609-
610-
# Loan date should be in UTC (as lib_datetime).
611-
loan_datetime = ciso8601.parse_datetime(loan_end_date)
612-
613-
# Compare year, month and date for Loan due date: should be the same!
614-
fail_msg = "Check timezone for Loan and Library. \
587+
# Freeze time around the checkout and the subsequent assertion so both see
588+
# the same instant. Without this, datetime.now() inside the loan creation
589+
# and datetime.now() in the assertion could straddle midnight (CET = UTC+1)
590+
# and produce different library-local calendar days, causing a spurious
591+
# failure when tests run after 23:00 UTC.
592+
frozen_now = datetime.now(timezone.utc)
593+
with freeze_time(frozen_now):
594+
# Checkout the item
595+
res, data = postdata(
596+
client,
597+
"api_item.checkout",
598+
{
599+
"item_pid": item_pid,
600+
"patron_pid": patron_pid,
601+
"transaction_location_pid": loc_public_martigny.pid,
602+
"transaction_user_pid": librarian_martigny.pid,
603+
},
604+
)
605+
assert res.status_code == 200
606+
607+
# Get Loan date (should be in UTC)
608+
loan_pid = data.get("action_applied")[LoanAction.CHECKOUT].get("pid")
609+
loan = Loan.get_record_by_pid(loan_pid)
610+
loan_end_date = loan.get("end_date")
611+
612+
# Get next library open date (should be next monday after X-1 days) where
613+
# X is checkout_duration. Use library local time, same as the loan
614+
# computation, so results match across UTC/local day boundaries.
615+
lib = Library.get_record_by_pid(item.library_pid)
616+
lib_tz = lib.get_timezone()
617+
soon = datetime.now(pytz.utc).astimezone(lib_tz) + timedelta(days=checkout_duration - 1)
618+
lib_datetime = lib.next_open(soon)
619+
620+
# Loan date should be in UTC (as lib_datetime).
621+
loan_datetime = ciso8601.parse_datetime(loan_end_date)
622+
623+
# Compare year, month and date for Loan due date: should be the same!
624+
fail_msg = "Check timezone for Loan and Library. \
615625
It should be the same date, even if timezone changed."
616-
assert loan_datetime.year == lib_datetime.year, fail_msg
617-
assert loan_datetime.month == lib_datetime.month, fail_msg
618-
assert loan_datetime.day == lib_datetime.day, fail_msg
619-
# Loan date differs regarding timezone, and day of the year (GMT+1/2).
620-
check_timezone_date(pytz.utc, loan_datetime, [21, 22])
626+
assert loan_datetime.year == lib_datetime.year, fail_msg
627+
assert loan_datetime.month == lib_datetime.month, fail_msg
628+
assert loan_datetime.day == lib_datetime.day, fail_msg
629+
# Loan date differs regarding timezone, and day of the year (GMT+1/2).
630+
check_timezone_date(pytz.utc, loan_datetime, [21, 22])
621631

622632

623633
def test_librarian_request_on_blocked_user(

tests/ui/circulation/test_actions_extend.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import ciso8601
2424
import pytest
25+
from freezegun import freeze_time
2526
from invenio_circulation.errors import CirculationException
2627

2728
from rero_ils.modules.circ_policies.api import CircPolicy
@@ -231,21 +232,27 @@ def test_extend_on_item_on_loan_with_no_requests(
231232

232233
app.config["CIRCULATION_POLICIES"]["extension"]["from_end_date"] = False
233234
initial_loan = loan.update(initial_loan_data, dbcommit=True, reindex=True)
234-
# Extend the loan
235+
# Freeze time so extend_loan() and the assertion both observe the same
236+
# instant. Without this, the two datetime.now() calls could straddle
237+
# midnight CET (UTC+1) when tests run after 23:00 UTC and produce
238+
# different library-local calendar days.
235239
params = {
236240
"transaction_location_pid": loc_public_martigny.pid,
237241
"transaction_user_pid": librarian_martigny.pid,
238242
}
239-
item, actions = item.extend_loan(**params)
240-
assert item.status == ItemStatus.ON_LOAN
241-
extended_loan = Loan.get_record_by_pid(initial_loan.pid)
243+
frozen_now = datetime.now(timezone.utc)
244+
with freeze_time(frozen_now):
245+
item, actions = item.extend_loan(**params)
246+
assert item.status == ItemStatus.ON_LOAN
247+
extended_loan = Loan.get_record_by_pid(initial_loan.pid)
242248

243-
expected_date = datetime.now() + timedelta(days=cipo["renewal_duration"])
244-
expected_date_eve = expected_date - timedelta(days=1)
245-
expected_date = lib_martigny.next_open(expected_date_eve)
249+
now_lib_tz = datetime.now(timezone.utc).astimezone(lib_martigny.get_timezone())
250+
expected_date = now_lib_tz + timedelta(days=cipo["renewal_duration"])
251+
expected_date_eve = expected_date - timedelta(days=1)
252+
expected_date = lib_martigny.next_open(expected_date_eve)
246253

247-
ext_end_date = ciso8601.parse_datetime(str(extended_loan.end_date))
248-
assert expected_date.strftime("%Y%m%d") == ext_end_date.strftime("%Y%m%d")
254+
ext_end_date = ciso8601.parse_datetime(str(extended_loan.end_date))
255+
assert expected_date.strftime("%Y%m%d") == ext_end_date.strftime("%Y%m%d")
249256

250257
# Reset the application configuration
251258
app.config["CIRCULATION_POLICIES"]["extension"] = settings

tests/ui/circulation/test_extend_external.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,12 @@ def test_item_loans_extend_duration(
7474
utc_end_date = now + duration
7575
# computed end date at the library timezone
7676
end_date = utc_end_date.astimezone(tz=lib_martigny.get_timezone())
77-
expected_utc_end_date = now + timedelta(days=policy["renewal_duration"])
78-
# expected end date at the library timezone
79-
expected_end_date = expected_utc_end_date.astimezone(lib_martigny.get_timezone())
77+
# Mirror the next_open logic used by get_extension_params so
78+
# the comparison is consistent across timezone/day boundaries.
79+
now_lib_tz = now.astimezone(lib_martigny.get_timezone())
80+
renewal_duration = policy["renewal_duration"]
81+
expected_due_eve = now_lib_tz + timedelta(days=renewal_duration - 1)
82+
expected_end_date = lib_martigny.next_open(expected_due_eve)
8083
assert end_date.strftime("%Y-%m-%d") == expected_end_date.strftime("%Y-%m-%d")
8184
assert end_date.hour == 23
8285
assert end_date.minute == 59

tests/ui/libraries/test_libraries_api.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,19 +68,20 @@ def next_weekday(d, weekday):
6868

6969
# CASE 1 :: basic tests. According to library settings:
7070
# * monday --> friday :: 6 AM --> closed
71-
# * monday --> friday :: 12 AM --> open
71+
# * monday --> friday :: 12 PM --> open
7272
# * saturday & sunday :: closed all day
73+
# Datetimes are localized to the library timezone so opening-hour
74+
# comparisons are not skewed by a UTC offset.
75+
tz = library.get_timezone()
7376
orginal_date = datetime.strptime("2020/08/17", "%Y/%m/%d") # random date
7477
for day_idx in range(5):
7578
test_date = next_weekday(orginal_date, day_idx)
76-
test_date = test_date.replace(hour=6, minute=0)
77-
assert not library.is_open(test_date)
78-
test_date = test_date.replace(hour=12)
79-
assert library.is_open(test_date)
79+
assert not library.is_open(tz.localize(test_date.replace(hour=6, minute=0)))
80+
assert library.is_open(tz.localize(test_date.replace(hour=12, minute=0)))
8081
test_date = next_weekday(orginal_date, 5)
81-
assert not library.is_open(test_date)
82+
assert not library.is_open(tz.localize(test_date))
8283
test_date = next_weekday(orginal_date, 6)
83-
assert not library.is_open(test_date)
84+
assert not library.is_open(tz.localize(test_date))
8485

8586
# CASE 2 :: Check single exception dates
8687
# * According to library setting, the '2018-12-15' day is an exception

0 commit comments

Comments
 (0)