Skip to content

Commit b68ac42

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 b68ac42

File tree

7 files changed

+118
-73
lines changed

7 files changed

+118
-73
lines changed

rero_ils/modules/items/api/circulation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ def checkout(self, current_loan, **kwargs):
462462
LocationsSearch().get_record_by_pid(kwargs.get("transaction_location_pid")).library.pid
463463
) or self.library_pid
464464
library = Library.get_record_by_pid(transaction_library_pid)
465-
if not library.is_open(action_params["end_date"], True):
465+
if not library.is_open(action_params["end_date"], True, date_only=True):
466466
# If library has no open dates, keep the default due date
467467
# to avoid circulation errors
468468
with suppress(LibraryNeverOpen):

rero_ils/modules/libraries/api.py

Lines changed: 29 additions & 5 deletions
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
)
@@ -245,8 +250,15 @@ def _get_exceptions_matching_date(self, date_to_check, day_only=False):
245250
else:
246251
yield exception
247252

248-
def is_open(self, date=None, day_only=False):
249-
"""Test library is open."""
253+
def is_open(self, date=None, day_only=False, date_only=False):
254+
"""Test library is open.
255+
256+
:param date_only: When True the time component of *date* is ignored;
257+
the check covers the whole calendar day in the library's local
258+
timezone. The midnight-sentinel fallback (h==m==s==0) is kept for
259+
callers that have not yet been updated.
260+
:type date_only: bool
261+
"""
250262
date = date or datetime.now(pytz.utc)
251263
is_open = False
252264
rule_hours = []
@@ -256,6 +268,18 @@ def is_open(self, date=None, day_only=False):
256268
date = date_string_to_utc(date)
257269
if isinstance(date, datetime) and date.tzinfo is None:
258270
date = date.replace(tzinfo=pytz.utc)
271+
# Prefer the explicit date_only flag; fall back to the midnight sentinel
272+
# (h==m==s==0) for callers that have not been updated yet. When true,
273+
# the time component is reset to midnight *after* converting to the
274+
# library's local timezone so that _is_betweentimes() performs a
275+
# whole-day open check using the correct calendar day.
276+
date_only_query = date_only or date.hour == date.minute == date.second == 0
277+
# Always evaluate in library local time so day names and opening hours
278+
# are compared in the correct timezone regardless of what tz the caller
279+
# passed in (UTC, local, etc.).
280+
date = date.astimezone(self.get_timezone())
281+
if date_only_query:
282+
date = date.replace(hour=0, minute=0, second=0, microsecond=0)
259283

260284
# STEP 1 :: check about regular rules
261285
# Each library could define if a specific weekday is open or closed.
@@ -306,7 +330,7 @@ def next_open(self, date=None, previous=False, ensure=False):
306330
date = parser.parse(date)
307331
add_day = -1 if previous else 1
308332
date += timedelta(days=add_day)
309-
while not self.is_open(date=date, day_only=True):
333+
while not self.is_open(date=date, day_only=True, date_only=True):
310334
date += timedelta(days=add_day)
311335
if not ensure:
312336
return date
@@ -326,7 +350,7 @@ def get_open_days(self, start_date=None, end_date=None):
326350
dates = []
327351
end_date += timedelta(days=1)
328352
while end_date > start_date:
329-
if self.is_open(date=start_date, day_only=True):
353+
if self.is_open(date=start_date, day_only=True, date_only=True):
330354
dates.append(start_date)
331355
start_date += timedelta(days=1)
332356
return dates

rero_ils/modules/libraries/api_views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def list_closed_dates(library_pid):
6363
closed_date = []
6464
for i in range(delta.days + 1):
6565
tmp_date = start_date + timedelta(days=i)
66-
if not library.is_open(date=tmp_date, day_only=True):
66+
if not library.is_open(date=tmp_date, day_only=True, date_only=True):
6767
closed_date.append(tmp_date.strftime("%Y-%m-%d"))
6868

6969
return jsonify(

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: 18 additions & 11 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)
242-
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)
246-
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")
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)
248+
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)
253+
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: 19 additions & 18 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
@@ -108,35 +109,35 @@ def next_weekday(d, weekday):
108109
# * According to library setting, each '1st augustus' is closed
109110
# (from 2019); despite if '2019-08-01' is a thursday (normally open)
110111
exception_date = date_string_to_utc("2019-08-01") # Thursday
111-
assert not library.is_open(exception_date)
112+
assert not library.is_open(exception_date, day_only=True, date_only=True)
112113
exception_date = date_string_to_utc("2022-08-01") # Monday
113-
assert not library.is_open(exception_date)
114+
assert not library.is_open(exception_date, day_only=True, date_only=True)
114115
exception_date = date_string_to_utc("2018-08-01") # Wednesday
115-
assert library.is_open(exception_date)
116+
assert library.is_open(exception_date, day_only=True, date_only=True)
116117
exception_date = date_string_to_utc("2222-8-1") # Thursday
117-
assert not library.is_open(exception_date)
118+
assert not library.is_open(exception_date, day_only=True, date_only=True)
118119

119120
# CASE 4 :: Check repeatable exception range date
120121
# * According to library setting, the library is closed for christmas
121122
# break each year (22/12 --> 06/01)
122123
exception_date = date_string_to_utc("2018-12-24") # Monday
123-
assert not library.is_open(exception_date)
124+
assert not library.is_open(exception_date, day_only=True, date_only=True)
124125
exception_date = date_string_to_utc("2019-01-07") # Monday
125-
assert library.is_open(exception_date)
126+
assert library.is_open(exception_date, day_only=True, date_only=True)
126127
exception_date = date_string_to_utc("2020-12-29") # Tuesday
127-
assert not library.is_open(exception_date)
128+
assert not library.is_open(exception_date, day_only=True, date_only=True)
128129
exception_date = date_string_to_utc("2101-01-4") # Tuesday
129-
assert not library.is_open(exception_date)
130+
assert not library.is_open(exception_date, day_only=True, date_only=True)
130131

131132
# CASE 5 :: Check repeatable date with interval
132133
# * According to library setting, each first day of the odd months is
133134
# a closed day.
134135
exception_date = date_string_to_utc("2019-03-01") # Friday
135-
assert not library.is_open(exception_date)
136+
assert not library.is_open(exception_date, day_only=True, date_only=True)
136137
exception_date = date_string_to_utc("2019-04-01") # Monday
137-
assert library.is_open(exception_date)
138+
assert library.is_open(exception_date, day_only=True, date_only=True)
138139
exception_date = date_string_to_utc("2019-05-01") # Wednesday
139-
assert not library.is_open(exception_date)
140+
assert not library.is_open(exception_date, day_only=True, date_only=True)
140141

141142
# Other tests on opening day/hour
142143
assert library.next_open(date=saturday).date() == parser.parse("2018-12-17").date()

0 commit comments

Comments
 (0)