From b4bb34fbe8c336077b4acb8cbdaff19698030b05 Mon Sep 17 00:00:00 2001 From: Jose Caballero Bejar Date: Tue, 13 Jan 2026 13:21:14 +0000 Subject: [PATCH 1/2] Allow end time on action "hypervisor.downtime" In this task we address the request from this JIRA ticket: https://stfc.atlassian.net/browse/CLDSCRM-2737 We want to allow to specify not only the number of hours that a given hypervisor is going to be down, but also: - the number of days - the number of days and hours - the exact end time In this commit we make the following changes: - we add a new field to the YAML file for the user form, and improve the description sentences - we implement the necessary code to allow those new input formats The new code has been implemented almost entirely as separate functions called by the old one. These new functions return the final total number of hours for the donwtime interval. It is true that, doing things this way, for some cases the datetime object for the endtime is calculated twice. The changes have been implemented this way for two reasons: - modify the original code a little as possible, avoiding the need to refactor too much existing unittest code - the new logic is implemented in separate small functions that return outputs. This way, we can unittest them in "black box" mode -checking only the inputs, outputs, and excpetions, but not the implementation- and still covering the entire logic A potential refactoring in the future could be to implement the time calculations we are introducing in this change inside the Dowmtime classes. But I would suggest to do that, if so, only when we do not need icinga downtimes anymore and this st2 Action requires some cleanup and simplification --- actions/hypervisor.downtime.yaml | 12 ++- lib/workflows/hypervisor_downtime.py | 120 ++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/actions/hypervisor.downtime.yaml b/actions/hypervisor.downtime.yaml index a08af21b0..34f224ec0 100644 --- a/actions/hypervisor.downtime.yaml +++ b/actions/hypervisor.downtime.yaml @@ -8,10 +8,14 @@ parameters: type: string description: Hostname of the hypervisor, e.g. hvxyz.nubes.rl.ac.uk required: true - duration_hours: - type: integer - description: Duration of downtime in hours, CANNOT be a decimal - required: true + end_time: + type: string + description: "Enter the end date of the downtime period, starting now, as a UTC timestamp in the format YYYY-MM-DD HH:MM. NOTE: if this field is left empty, then field 'duration' is required." + required: false + duration: + type: string + description: "Enter the duration of the downtime, starting now, as a number of days (d) and/or hours (h) (examples: 2d, 24h, 3d 12h). NOTE: if this field is left empty, then field 'end_time' is required." + required: false comment: type: string description: Downtime reason diff --git a/lib/workflows/hypervisor_downtime.py b/lib/workflows/hypervisor_downtime.py index fa0cdefab..adee8df95 100644 --- a/lib/workflows/hypervisor_downtime.py +++ b/lib/workflows/hypervisor_downtime.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta - +import re import pytz from apis.icinga_api.downtime import schedule_downtime @@ -13,19 +13,135 @@ from apis.alertmanager_api.structs.silence_details import SilenceDetails +def get_number_of_hours(start_dt, end_time_str, duration): + """ + Get the total number of hours between a start time and an end time + + :param start_dt: start date + :type start_dt: datetime object + + :param end_time_str: a datetime string in format "YYYY-MM-DD HH:MM" + :type end_time_str: str + + :param duration: a duration string like "5d 12h", "3d", "24h", etc. + :type duration: str + + :return: the number of hours to end date + :rtype: int + + :raises ValueError: if the input string is invalid or contains negative values + """ + end_time_str = end_time_str.strip() + duration = duration.strip() + if not end_time_str and not duration: + raise ValueError("Input strings cannot be both empty at the same time") + + if end_time_str and duration: + raise ValueError("Input strings cannot be both valid at the same time") + + if end_time_str: + return _get_number_of_hours_from_absolute_datetime(start_dt, end_time_str) + + if duration: + return _get_number_of_hours_from_duration(duration) + + # if we are still here is because no return statement has been issued + # meaning the input did not match any of the valid formats + raise ValueError( + "Invalid input format. Expected either 'YYYY-MM-DD HH:MM' " + f"or duration format like '5d', '12h', '2d 6h'. Got: '{end_time_str}'" + ) + + +def _get_number_of_hours_from_absolute_datetime(start_dt, datetime_str): + """ + Get the total number of hours from a start time and an end time + + :param start_dt: start time + :type start_dt: datetime object + + :param datetime_str: datetime string in format "YYYY-MM-DD HH:MM" + :type end_time_str: str + + :return: the number of hours to end date + :rtype: int + + :raises ValueError: if the input string is invalid + :raises ValueError: if the end date is earlier that start date + """ + try: + end_dt = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + except ValueError as exc: + raise ValueError( + f"Invalid datetime format. Expected 'YYYY-MM-DD HH:MM', got '{datetime_str}'" + ) from exc + if start_dt >= end_dt: + raise ValueError("end time cannot be earlier than now") + duration = end_dt - start_dt + # Convert total seconds to hours + hours = int(duration.total_seconds() / 3600) + return hours + + +def _get_number_of_hours_from_duration(duration_str): + """ + Get the total number of hours from a duration string + + :param duration_str: duration string like "5d 12h", "3d", "24h", etc. + :type duration_str: str + + :return: the number of hours to end date + :rtype: int + + :raises ValueError: if the input string has wrong format + """ + duration_str = duration_str.strip() + # Check for negative signs + if "-" in duration_str: + raise ValueError( + "Negative durations are not allowed. Only positive numbers are permitted." + ) + + days = 0 + hours = 0 + + # Search for days pattern + days_match = re.search(r"(\d+)d", duration_str, re.IGNORECASE) + if days_match: + days = int(days_match.group(1)) + + # Search for hours pattern + hours_match = re.search(r"(\d+)h", duration_str, re.IGNORECASE) + if hours_match: + hours = int(hours_match.group(1)) + + # Validate that at least one unit was found + if days == 0 and hours == 0: + raise ValueError( + f"Invalid duration format. Expected format like '5d', '12h', or '2d 6h', got '{duration_str}'" + ) + + total_hours = days * 24 + hours + return total_hours + + # pylint:disable=too-many-locals def schedule_hypervisor_downtime( icinga_account: IcingaAccount, alertmanager_account: AlertManagerAccount, hypervisor_name: str, comment: str, - duration_hours: int, + end_time: str, + duration: str, set_silence: bool, set_downtime: bool, ): # Local UK time to Unix timestamp start_datetime = datetime.now(pytz.utc) + duration_hours = get_number_of_hours(start_datetime, end_time, duration) end_datetime = start_datetime + timedelta(hours=duration_hours) start_timestamp = int(start_datetime.timestamp()) From dee169df04c6f56556d3f21ca631f164be1ddf8d Mon Sep 17 00:00:00 2001 From: Jose Caballero Bejar Date: Tue, 13 Jan 2026 13:33:03 +0000 Subject: [PATCH 2/2] Add unit test for new code Fix the old unittest as the signature for the original function has changed a little bit. Add tests for the new functions introduced in this task. --- .../workflows/test_hypervisor_downtimes.py | 655 +++++++++++++++++- 1 file changed, 651 insertions(+), 4 deletions(-) diff --git a/tests/lib/workflows/test_hypervisor_downtimes.py b/tests/lib/workflows/test_hypervisor_downtimes.py index ac583d96c..beb035fa0 100644 --- a/tests/lib/workflows/test_hypervisor_downtimes.py +++ b/tests/lib/workflows/test_hypervisor_downtimes.py @@ -5,10 +5,14 @@ from apis.alertmanager_api.structs.alert_matcher_details import AlertMatcherDetails from apis.alertmanager_api.structs.silence_details import SilenceDetails from apis.icinga_api.structs.downtime_details import DowntimeDetails -from workflows.hypervisor_downtime import schedule_hypervisor_downtime import pytest - import pytz +from workflows.hypervisor_downtime import schedule_hypervisor_downtime +from workflows.hypervisor_downtime import ( + get_number_of_hours, + _get_number_of_hours_from_absolute_datetime, + _get_number_of_hours_from_duration, +) # pylint:disable=too-many-locals @@ -35,8 +39,12 @@ def test_successful_schedule_hypervisor_downtime( icinga_account = MagicMock() mock_hypervisor_name = "test_host" comment = f"starting downtime to patch and reboot host: {mock_hypervisor_name}" - mock_duration = 7 mock_start_time = datetime.datetime.now(pytz.utc) + mock_input_end_time = "" + mock_input_duration = "7h" + mock_duration = get_number_of_hours( + mock_start_time, mock_input_end_time, mock_input_duration + ) mock_end_time = mock_start_time + datetime.timedelta(hours=mock_duration) mock_start_timestamp = int(mock_start_time.timestamp()) mock_end_timestamp = int(mock_end_time.timestamp()) @@ -48,7 +56,8 @@ def test_successful_schedule_hypervisor_downtime( alertmanager_account, hypervisor_name=mock_hypervisor_name, comment=comment, - duration_hours=mock_duration, + end_time=mock_input_end_time, + duration=mock_input_duration, set_silence=set_silence, set_downtime=set_downtime, ) @@ -116,3 +125,641 @@ def test_unsuccessful_schedule_hypervisor_downtime( ) mock_schedule_downtime.assert_not_called() + + +# =============================================== +# unit test function get_number_of_hours() +# =============================================== + + +def test_both_empty_strings_raise_exception(): + """Test that both empty strings raise ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + get_number_of_hours(start_dt, "", "") + + +def test_both_single_space_raise_exception(): + """Test that both parameters as single space raise ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + get_number_of_hours(start_dt, " ", " ") + + +def test_both_multiple_spaces_raise_exception(): + """Test that both parameters as multiple spaces raise ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + get_number_of_hours(start_dt, " ", " ") + + +def test_both_tabs_and_spaces_raise_exception(): + """Test that both parameters as tabs and spaces raise ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + get_number_of_hours(start_dt, "\t \t", "\t \t") + + +def test_both_parameters_valid_raise_exception(): + """Test that providing both valid end_time_str and duration raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + get_number_of_hours(start_dt, "2024-01-01 15:00", "5d") + + +def test_both_parameters_with_spaces_valid_raise_exception(): + """Test that providing both valid parameters with spaces raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + get_number_of_hours(start_dt, " 2024-01-01 15:00 ", " 5d ") + + +def test_invalid_endtime_input_raise_exception(): + """Test that an invalid input raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + get_number_of_hours(start_dt, "invalid", "") + + +def test_invalid_duration_input_raise_exception(): + """Test that an invalid input raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + get_number_of_hours(start_dt, "", "invalid") + + +def test_valid_absolute_datetime_same_day(): + """Test valid absolute datetime on the same day""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "2024-01-01 15:00", "") + assert result == 5 + + +def test_valid_absolute_datetime_next_day(): + """Test valid absolute datetime on the next day""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "2024-01-02 10:00", "") + assert result == 24 + + +def test_valid_absolute_datetime_with_leading_trailing_spaces(): + """Test valid absolute datetime with leading/trailing spaces""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, " 2024-01-01 14:00 ", "") + assert result == 4 + + +def test_valid_absolute_datetime_duration_empty(): + """Test valid absolute datetime with empty duration parameter""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "2024-01-01 20:00", "") + assert result == 10 + + +def test_valid_duration_days_only(): + """Test valid duration with days only""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "", "5d") + assert result == 120 + + +def test_valid_duration_single_day(): + """Test valid duration with single day""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "", "1d") + assert result == 24 + + +def test_valid_duration_hours_only(): + """Test valid duration with hours only""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "", "12h") + assert result == 12 + + +def test_valid_duration_single_hour(): + """Test valid duration with single hour""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "", "1h") + assert result == 1 + + +def test_valid_duration_days_and_hours(): + """Test valid duration with both days and hours""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "", "5d 12h") + assert result == 132 + + +def test_valid_duration_hours_and_days_reversed(): + """Test valid duration with hours before days""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "", "12h 5d") + assert result == 132 + + +def test_valid_duration_case_insensitive(): + """Test that duration format is case insensitive""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "", "2D 6H") + assert result == 54 + + +def test_valid_duration_with_leading_trailing_spaces(): + """Test valid duration with leading/trailing spaces""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "", " 3d ") + assert result == 72 + + +def test_valid_duration_end_time_empty(): + """Test valid duration with empty end_time_str parameter""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = get_number_of_hours(start_dt, "", "48h") + assert result == 48 + + +def test_empty_end_time_whitespace_duration(): + """Test empty end_time_str and whitespace duration""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + get_number_of_hours(start_dt, "", " ") + + +def test_whitespace_end_time_empty_duration(): + """Test whitespace end_time_str and empty duration""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + get_number_of_hours(start_dt, " ", "") + + +def test_invalid_absolute_datetime_format(): + """Test that invalid datetime format is caught by helper function""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + # This will be caught by get_number_of_hours_from_absolute_datetime + with pytest.raises(ValueError): + get_number_of_hours(start_dt, "2024-01-01", "") + + +def test_invalid_duration_format(): + """Test that invalid duration format is caught by helper function""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + # This will be caught by _get_number_of_hours_from_duration + with pytest.raises(ValueError): + get_number_of_hours(start_dt, "", "invalid") + + +# =============================================== +# unit test function _get_number_of_hours_from_absolute_datetime() +# =============================================== + + +def test_absolute_datetime_empty_string_raises_exception(): + """Test that an empty string raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "") + + +def test_absolute_datetime_invalid_format_no_time(): + """Test that date without time raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-01") + + +def test_absolute_datetime_invalid_format_wrong_separator(): + """Test that wrong separator (T instead of space) raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-01T10:00") + + +def test_absolute_datetime_invalid_format_wrong_date_separator(): + """Test that wrong date separator (slashes) raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "2024/01/01 10:00") + + +def test_absolute_datetime_invalid_format_random_text(): + """Test that random text raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "random text") + + +def test_absolute_datetime_invalid_month(): + """Test that invalid month raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "2024-13-01 10:00") + + +def test_absolute_datetime_invalid_day(): + """Test that invalid day raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-32 10:00") + + +def test_absolute_datetime_invalid_hour(): + """Test that invalid hour raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-01 25:00") + + +def test_absolute_datetime_invalid_minute(): + """Test that invalid minute raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-01 10:60") + + +def test_absolute_datetime_end_before_start(): + """Test that end time before start time raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-01 09:00") + + +def test_absolute_datetime_end_day_before_start(): + """Test that end date before start date raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-02 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-01 10:00") + + +def test_absolute_datetime_same_time(): + """Test that same start and end time raises ValueError""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + with pytest.raises(ValueError): + _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-01 10:00") + + +def test_absolute_datetime_one_hour_later(): + """Test one hour difference""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-01 11:00") + assert result == 1 + + +def test_absolute_datetime_same_day_multiple_hours(): + """Test multiple hours on same day""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-01 15:00") + assert result == 5 + + +def test_absolute_datetime_exactly_one_day(): + """Test exactly 24 hours (one day)""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-02 10:00") + assert result == 24 + + +def test_absolute_datetime_multiple_days(): + """Test multiple days""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-05 10:00") + assert result == 96 + + +def test_absolute_datetime_days_and_hours(): + """Test combination of days and hours""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-03 14:00") + assert result == 52 + + +def test_absolute_datetime_across_month_boundary(): + """Test datetime across month boundary""" + start_dt = datetime.datetime.strptime("2024-01-30 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = _get_number_of_hours_from_absolute_datetime(start_dt, "2024-02-01 10:00") + assert result == 48 + + +def test_absolute_datetime_across_year_boundary(): + """Test datetime across year boundary""" + start_dt = datetime.datetime.strptime("2024-12-31 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = _get_number_of_hours_from_absolute_datetime(start_dt, "2025-01-01 10:00") + assert result == 24 + + +def test_absolute_datetime_with_minutes_rounds_down(): + """Test that minutes are truncated when converting to hours""" + start_dt = datetime.datetime.strptime("2024-01-01 10:00", "%Y-%m-%d %H:%M").replace( + tzinfo=pytz.utc + ) + result = _get_number_of_hours_from_absolute_datetime(start_dt, "2024-01-01 11:59") + assert result == 1 + + +# =============================================== +# unit test function _get_number_of_hours_from_duration() +# =============================================== + + +def test_duration_empty_string_raises_exception(): + """Test that an empty string raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("") + + +def test_duration_single_space_raises_exception(): + """Test that a single space raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration(" ") + + +def test_duration_multiple_spaces_raises_exception(): + """Test that multiple spaces raise ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration(" ") + + +def test_duration_tabs_and_spaces_raises_exception(): + """Test that tabs and spaces raise ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("\t \t") + + +def test_duration_invalid_format_random_text(): + """Test that random text raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("random text") + + +def test_duration_invalid_format_only_number(): + """Test that a plain number without unit raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("123") + + +def test_duration_invalid_format_wrong_unit(): + """Test that invalid duration units raise ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("5m") + + +def test_duration_invalid_format_multiple_wrong_units(): + """Test that multiple invalid units raise ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("5m 30s") + + +def test_duration_invalid_format_datetime_string(): + """Test that datetime string raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("2024-01-01 10:00") + + +def test_duration_invalid_format_only_units_no_numbers(): + """Test that units without numbers raise ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("d h") + + +def test_duration_invalid_format_only_d(): + """Test that only 'd' without number raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("d") + + +def test_duration_invalid_format_only_h(): + """Test that only 'h' without number raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("h") + + +def test_duration_zero_days_zero_hours(): + """Test that 0d 0h raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("0d 0h") + + +def test_duration_zero_days_only(): + """Test that 0d raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("0d") + + +def test_duration_zero_hours_only(): + """Test that 0h raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("0h") + + +def test_invalid_format_negative_duration(): + """Test that negative duration raises ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("-5d") + + +def test_invalid_format_negative_hours(): + """Test that negative hours raise ValueError""" + with pytest.raises(ValueError): + _get_number_of_hours_from_duration("-12h") + + +def test_duration_single_day(): + """Test valid duration with single day""" + result = _get_number_of_hours_from_duration("1d") + assert result == 24 + + +def test_duration_multiple_days(): + """Test valid duration with multiple days""" + result = _get_number_of_hours_from_duration("5d") + assert result == 120 + + +def test_duration_single_hour(): + """Test valid duration with single hour""" + result = _get_number_of_hours_from_duration("1h") + assert result == 1 + + +def test_duration_multiple_hours(): + """Test valid duration with multiple hours""" + result = _get_number_of_hours_from_duration("12h") + assert result == 12 + + +def test_duration_twenty_four_hours(): + """Test valid duration with exactly 24 hours""" + result = _get_number_of_hours_from_duration("24h") + assert result == 24 + + +def test_duration_days_and_hours(): + """Test valid duration with both days and hours""" + result = _get_number_of_hours_from_duration("5d 12h") + assert result == 132 + + +def test_duration_hours_and_days_reversed(): + """Test valid duration with hours before days""" + result = _get_number_of_hours_from_duration("12h 5d") + assert result == 132 + + +def test_duration_days_and_hours_multiple_spaces(): + """Test valid duration with multiple spaces between units""" + result = _get_number_of_hours_from_duration("2d 6h") + assert result == 54 + + +def test_duration_days_and_hours_no_space(): + """Test valid duration with no space between units""" + result = _get_number_of_hours_from_duration("2d6h") + assert result == 54 + + +def test_duration_case_insensitive_lowercase(): + """Test that duration format accepts lowercase units""" + result = _get_number_of_hours_from_duration("2d 6h") + assert result == 54 + + +def test_duration_case_insensitive_uppercase(): + """Test that duration format accepts uppercase units""" + result = _get_number_of_hours_from_duration("2D 6H") + assert result == 54 + + +def test_duration_case_insensitive_mixed_case(): + """Test that duration format accepts mixed case units""" + result = _get_number_of_hours_from_duration("2D 6h") + assert result == 54 + + +def test_duration_large_number_of_days(): + """Test valid duration with large number of days""" + result = _get_number_of_hours_from_duration("100d") + assert result == 2400 + + +def test_duration_large_number_of_hours(): + """Test valid duration with large number of hours""" + result = _get_number_of_hours_from_duration("1000h") + assert result == 1000 + + +def test_duration_with_leading_spaces(): + """Test valid duration with leading spaces""" + result = _get_number_of_hours_from_duration(" 5d 12h") + assert result == 132 + + +def test_duration_with_trailing_spaces(): + """Test valid duration with trailing spaces""" + result = _get_number_of_hours_from_duration("5d 12h ") + assert result == 132 + + +def test_duration_with_leading_and_trailing_spaces(): + """Test valid duration with leading and trailing spaces""" + result = _get_number_of_hours_from_duration(" 5d 12h ") + assert result == 132 + + +def test_duration_one_day_one_hour(): + """Test valid duration with one day and one hour""" + result = _get_number_of_hours_from_duration("1d 1h") + assert result == 25 + + +def test_duration_ten_days(): + """Test valid duration with ten days""" + result = _get_number_of_hours_from_duration("10d") + assert result == 240