diff --git a/coldfront/core/allocation/test_models.py b/coldfront/core/allocation/test_models.py deleted file mode 100644 index 3adef2d49..000000000 --- a/coldfront/core/allocation/test_models.py +++ /dev/null @@ -1,24 +0,0 @@ -# SPDX-FileCopyrightText: (C) ColdFront Authors -# -# SPDX-License-Identifier: AGPL-3.0-or-later - -"""Unit tests for the allocation models""" - -from django.test import TestCase - -from coldfront.core.test_helpers.factories import AllocationFactory, ResourceFactory - - -class AllocationModelTests(TestCase): - """tests for Allocation model""" - - @classmethod - def setUpTestData(cls): - """Set up project to test model properties and methods""" - cls.allocation = AllocationFactory() - cls.allocation.resources.add(ResourceFactory(name="holylfs07/tier1")) - - def test_allocation_str(self): - """test that allocation str method returns correct string""" - allocation_str = "%s (%s)" % (self.allocation.get_parent_resource.name, self.allocation.project.pi) - self.assertEqual(str(self.allocation), allocation_str) diff --git a/coldfront/core/allocation/tests/__init__.py b/coldfront/core/allocation/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/coldfront/core/allocation/tests/test_models.py b/coldfront/core/allocation/tests/test_models.py new file mode 100644 index 000000000..139a23772 --- /dev/null +++ b/coldfront/core/allocation/tests/test_models.py @@ -0,0 +1,437 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Unit tests for the allocation models""" + +import datetime +import typing +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone +from django.utils.html import format_html +from django.utils.safestring import SafeString + +from coldfront.core.allocation.models import ( + Allocation, + AllocationAttribute, + AllocationStatusChoice, +) +from coldfront.core.project.models import Project +from coldfront.core.test_helpers.factories import ( + AllocationAttributeFactory, + AllocationAttributeTypeFactory, + AllocationAttributeUsageFactory, + AllocationFactory, + AllocationStatusChoiceFactory, + ProjectFactory, + ResourceFactory, +) + + +class AllocationModelTests(TestCase): + """tests for Allocation model""" + + @classmethod + def setUpTestData(cls): + """Set up project to test model properties and methods""" + cls.allocation = AllocationFactory() + cls.allocation.resources.add(ResourceFactory(name="holylfs07/tier1")) + + def test_allocation_str(self): + """test that allocation str method returns correct string""" + allocation_str = "%s (%s)" % (self.allocation.get_parent_resource.name, self.allocation.project.pi) + self.assertEqual(str(self.allocation), allocation_str) + + +class AllocationModelCleanMethodTests(TestCase): + """tests for Allocation model clean method""" + + @classmethod + def setUpTestData(cls): + """Set up allocation to test clean method""" + cls.active_status: AllocationStatusChoice = AllocationStatusChoiceFactory(name="Active") + cls.expired_status: AllocationStatusChoice = AllocationStatusChoiceFactory(name="Expired") + cls.project: Project = ProjectFactory() + + def test_status_is_expired_and_no_end_date_has_validation_error(self): + """Test that an allocation with status 'expired' and no end date raises a validation error.""" + actual_allocation: Allocation = AllocationFactory.build( + status=self.expired_status, end_date=None, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_expired_and_end_date_not_past_has_validation_error(self): + """Test that an allocation with status 'expired' and end date in the future raises a validation error.""" + end_date_in_the_future: datetime.date = (timezone.now() + datetime.timedelta(days=1)).date() + actual_allocation: Allocation = AllocationFactory.build( + status=self.expired_status, end_date=end_date_in_the_future, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_expired_and_start_date_after_end_date_has_validation_error(self): + """Test that an allocation with status 'expired' and start date after end date raises a validation error.""" + end_date: datetime.date = (timezone.now() + datetime.timedelta(days=1)).date() + start_date_after_end_date: datetime.date = end_date + datetime.timedelta(days=1) + + actual_allocation: Allocation = AllocationFactory.build( + status=self.expired_status, start_date=start_date_after_end_date, end_date=end_date, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_expired_and_start_date_before_end_date_no_error(self): + """Test that an allocation with status 'expired' and start date before end date does not raise a validation error.""" + start_date: datetime.date = datetime.datetime(year=2023, month=11, day=2, tzinfo=timezone.utc).date() + end_date: datetime.date = start_date + datetime.timedelta(days=40) + + actual_allocation: Allocation = AllocationFactory.build( + status=self.expired_status, start_date=start_date, end_date=end_date, project=self.project + ) + actual_allocation.full_clean() + + def test_status_is_expired_and_start_date_equals_end_date_no_error(self): + """Test that an allocation with status 'expired' and start date equal to end date does not raise a validation error.""" + start_and_end_date: datetime.date = datetime.datetime(year=1997, month=4, day=20, tzinfo=timezone.utc).date() + + actual_allocation: Allocation = AllocationFactory.build( + status=self.expired_status, start_date=start_and_end_date, end_date=start_and_end_date, project=self.project + ) + actual_allocation.full_clean() + + def test_status_is_active_and_no_start_date_has_validation_error(self): + """Test that an allocation with status 'active' and no start date raises a validation error.""" + actual_allocation: Allocation = AllocationFactory.build( + status=self.active_status, start_date=None, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_active_and_no_end_date_has_validation_error(self): + """Test that an allocation with status 'active' and no end date raises a validation error.""" + actual_allocation: Allocation = AllocationFactory.build( + status=self.active_status, end_date=None, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_active_and_start_date_after_end_date_has_validation_error(self): + """Test that an allocation with status 'active' and start date after end date raises a validation error.""" + end_date: datetime.date = (timezone.now() + datetime.timedelta(days=1)).date() + start_date_after_end_date: datetime.date = end_date + datetime.timedelta(days=1) + + actual_allocation: Allocation = AllocationFactory.build( + status=self.active_status, start_date=start_date_after_end_date, end_date=end_date, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_active_and_start_date_before_end_date_no_error(self): + """Test that an allocation with status 'active' and start date before end date does not raise a validation error.""" + start_date: datetime.date = datetime.datetime(year=2001, month=5, day=3, tzinfo=timezone.utc).date() + end_date: datetime.date = start_date + datetime.timedelta(days=160) + + actual_allocation: Allocation = AllocationFactory.build( + status=self.active_status, start_date=start_date, end_date=end_date, project=self.project + ) + actual_allocation.full_clean() + + def test_status_is_active_and_start_date_equals_end_date_no_error(self): + """Test that an allocation with status 'active' and start date equal to end date does not raise a validation error.""" + start_and_end_date: datetime.date = datetime.datetime(year=2005, month=6, day=3, tzinfo=timezone.utc).date() + + actual_allocation: Allocation = AllocationFactory.build( + status=self.active_status, start_date=start_and_end_date, end_date=start_and_end_date, project=self.project + ) + actual_allocation.full_clean() + + +class AllocationFuncOnExpireException(Exception): + """Custom exception for testing allocation expiration function in the AllocationModelSaveMethodTests class.""" + + pass + + +def allocation_func_on_expire_exception(*args, **kwargs): + """Test function to be called on allocation expiration in the AllocationModelSaveMethodTests class.""" + raise AllocationFuncOnExpireException("This is a test exception for allocation expiration.") + + +def get_dotted_path(func): + """Return the dotted path string for a Python function in the AllocationModelSaveMethodTests class.""" + return f"{func.__module__}.{func.__qualname__}" + + +NUMBER_OF_INVOCATIONS = 12 + + +def count_invocations(*args, **kwargs): + count_invocations.invocation_count = getattr(count_invocations, "invocation_count", 0) + 1 # type: ignore + + +def count_invocations_negative(*args, **kwargs): + count_invocations_negative.invocation_count = getattr(count_invocations_negative, "invocation_count", 0) - 1 # type: ignore + + +def list_of_same_expire_funcs(func: typing.Callable, size=NUMBER_OF_INVOCATIONS) -> list[str]: + return [get_dotted_path(func) for _ in range(size)] + + +def list_of_different_expire_funcs() -> list[str]: + """Return a list of different functions to be called on allocation expiration. + The list will have a length of NUMBER_OF_INVOCATIONS, with the last function being allocation_func_on_expire_exception. + If NUMBER_OF_INVOCATIONS is odd, the list will contain (NUMBER_OF_INVOCATIONS // 2) instances of count_invocations and (NUMBER_OF_INVOCATIONS // 2) instances of count_invocations_negative. + If NUMBER_OF_INVOCATIONS is even, the list will contain (NUMBER_OF_INVOCATIONS // 2) instances of count_invocations and ((NUMBER_OF_INVOCATIONS // 2)-1) instances of count_invocations_negative. + """ + expire_funcs: list[str] = [] + for i in range(NUMBER_OF_INVOCATIONS): + if i == (NUMBER_OF_INVOCATIONS - 1): + expire_funcs.append(get_dotted_path(allocation_func_on_expire_exception)) + elif i % 2 == 0: + expire_funcs.append(get_dotted_path(count_invocations)) + else: + expire_funcs.append(get_dotted_path(count_invocations_negative)) + return expire_funcs + + +class AllocationModelSaveMethodTests(TestCase): + path_to_allocation_models_funcs_on_expire: str = "coldfront.core.allocation.models.ALLOCATION_FUNCS_ON_EXPIRE" + + def setUp(self): + count_invocations.invocation_count = 0 # type: ignore + count_invocations_negative.invocation_count = 0 # type: ignore + + @classmethod + def setUpTestData(cls): + """Set up allocation to test clean method""" + cls.active_status: AllocationStatusChoice = AllocationStatusChoiceFactory(name="Active") + cls.expired_status: AllocationStatusChoice = AllocationStatusChoiceFactory(name="Expired") + cls.other_status: AllocationStatusChoice = AllocationStatusChoiceFactory(name="Other") + cls.project: Project = ProjectFactory() + + @patch(path_to_allocation_models_funcs_on_expire, list_of_same_expire_funcs(allocation_func_on_expire_exception, 1)) + def test_on_expiration_calls_single_func_in_funcs_on_expire(self): + """Test that the allocation save method calls the functions specified in ALLOCATION_FUNCS_ON_EXPIRE when it expires.""" + allocation = AllocationFactory(status=self.active_status) + with self.assertRaises(AllocationFuncOnExpireException): + allocation.status = self.expired_status + allocation.save() + + @patch(path_to_allocation_models_funcs_on_expire, list_of_same_expire_funcs(count_invocations)) + def test_on_expiration_calls_multiple_funcs_in_funcs_on_expire(self): + """Test that the allocation save method calls a function multiple times when ALLOCATION_FUNCS_ON_EXPIRE has multiple instances of it.""" + allocation = AllocationFactory(status=self.active_status) + allocation.status = self.expired_status + allocation.save() + self.assertEqual(count_invocations.invocation_count, NUMBER_OF_INVOCATIONS) # type: ignore + + @patch(path_to_allocation_models_funcs_on_expire, list_of_different_expire_funcs()) + def test_on_expiration_calls_multiple_different_funcs_in_funcs_on_expire(self): + """Test that the allocation save method calls all the different functions present in the list ALLOCATION_FUNCS_ON_EXPIRE.""" + allocation = AllocationFactory(status=self.active_status) + allocation.status = self.expired_status + + # the last function in the list is allocation_func_on_expire_exception, which raises an exception + with self.assertRaises(AllocationFuncOnExpireException): + allocation.save() + + # the other functions will have been called a different number of times depending on whether NUMBER_OF_INVOCATIONS is odd or even + if NUMBER_OF_INVOCATIONS % 2 == 0: + expected_positive_invocations = NUMBER_OF_INVOCATIONS // 2 + expected_negative_invocations = -((NUMBER_OF_INVOCATIONS // 2) - 1) + self.assertEqual(count_invocations.invocation_count, expected_positive_invocations) # type: ignore + self.assertEqual(count_invocations_negative.invocation_count, expected_negative_invocations) # type: ignore + else: + expected_positive_invocations = NUMBER_OF_INVOCATIONS // 2 + expected_negative_invocations = -(NUMBER_OF_INVOCATIONS // 2) + self.assertEqual(count_invocations.invocation_count, expected_positive_invocations) # type: ignore + self.assertEqual(count_invocations_negative.invocation_count, expected_negative_invocations) # type: ignore + + @patch(path_to_allocation_models_funcs_on_expire, list_of_same_expire_funcs(allocation_func_on_expire_exception, 1)) + def test_no_expire_no_funcs_on_expire_called(self): + """Test that the allocation save method does not call any functions when the allocation is not expired.""" + allocation = AllocationFactory(status=self.active_status) + allocation.save() + + @patch(path_to_allocation_models_funcs_on_expire, list_of_same_expire_funcs(allocation_func_on_expire_exception, 1)) + def test_allocation_changed_but_always_expired_no_funcs_on_expire_called(self): + """Test that the allocation save method does not call any functions when the allocation is always expired.""" + allocation = AllocationFactory(status=self.expired_status) + allocation.justification = "This allocation is always expired." + allocation.save() + + @patch(path_to_allocation_models_funcs_on_expire, list_of_same_expire_funcs(allocation_func_on_expire_exception, 1)) + def test_allocation_changed_but_never_expired_no_funcs_on_expire_called(self): + """Test that the allocation save method does not call any functions when the allocation is never expired.""" + allocation = AllocationFactory(status=self.active_status) + allocation.status = self.other_status + allocation.save() + + @patch(path_to_allocation_models_funcs_on_expire, list_of_same_expire_funcs(allocation_func_on_expire_exception, 1)) + def test_allocation_always_expired_no_funcs_on_expire_called(self): + """Test that the allocation save method does not call any functions when the allocation is always expired.""" + allocation = AllocationFactory(status=self.expired_status) + allocation.justification = "This allocation is always expired." + allocation.save() + + @patch(path_to_allocation_models_funcs_on_expire, list_of_same_expire_funcs(allocation_func_on_expire_exception, 1)) + def test_allocation_reactivated_no_funcs_on_expire_called(self): + """Test that the allocation save method does not call any functions when the allocation is reactivated.""" + allocation = AllocationFactory(status=self.expired_status) + allocation.status = self.active_status + allocation.save() + + @patch(path_to_allocation_models_funcs_on_expire, list()) + def test_new_allocation_is_in_database(self): + """Test that a new allocation is saved in the database.""" + allocation: Allocation = AllocationFactory(status=self.active_status) + allocation.save() + self.assertTrue(Allocation.objects.filter(id=allocation.id).exists()) + + @patch(path_to_allocation_models_funcs_on_expire, list()) + def test_multiple_new_allocations_are_in_database(self): + """Test that multiple new allocations are saved in the database.""" + allocations = [AllocationFactory(status=self.active_status) for _ in range(25)] + for allocation in allocations: + self.assertTrue(Allocation.objects.filter(id=allocation.id).exists()) + + +class AllocationModelExpiresInTests(TestCase): + mocked_today = datetime.date(2025, 1, 1) + three_years_after_mocked_today = datetime.date(2028, 1, 1) + four_years_after_mocked_today = datetime.date(2029, 1, 1) + + def test_end_date_is_today_returns_zero(self): + """Test that the expires_in method returns 0 when the end date is today.""" + allocation: Allocation = AllocationFactory(end_date=timezone.now().date()) + self.assertEqual(allocation.expires_in, 0) + + def test_end_date_tomorrow_returns_one(self): + """Test that the expires_in method returns 1 when the end date is tomorrow.""" + tomorrow: datetime.date = (timezone.now() + datetime.timedelta(days=1)).date() + allocation: Allocation = AllocationFactory(end_date=tomorrow) + self.assertEqual(allocation.expires_in, 1) + + def test_end_date_yesterday_returns_negative_one(self): + """Test that the expires_in method returns -1 when the end date is yesterday.""" + yesterday: datetime.date = (timezone.now() - datetime.timedelta(days=1)).date() + allocation: Allocation = AllocationFactory(end_date=yesterday) + self.assertEqual(allocation.expires_in, -1) + + def test_end_date_one_week_ago_returns_negative_seven(self): + """Test that the expires_in method returns -7 when the end date is one week ago.""" + days_in_a_week: int = 7 + one_week_ago: datetime.date = (timezone.now() - datetime.timedelta(days=days_in_a_week)).date() + allocation: Allocation = AllocationFactory(end_date=one_week_ago) + self.assertEqual(allocation.expires_in, -days_in_a_week) + + def test_end_date_in_one_week_returns_seven(self): + """Test that the expires_in method returns 7 when the end date is in one week.""" + days_in_a_week: int = 7 + one_week_from_now: datetime.date = (timezone.now() + datetime.timedelta(days=days_in_a_week)).date() + allocation: Allocation = AllocationFactory(end_date=one_week_from_now) + self.assertEqual(allocation.expires_in, days_in_a_week) + + def test_end_date_in_three_years_without_leap_day_returns_days_including_no_leap_day(self): + """Test that the expires_in method returns the correct number of days in three years when those years did not have a leap year.""" + days_in_three_years_excluding_leap_year = 365 * 3 + + with patch("coldfront.core.allocation.models.datetime") as mock_datetime: + mock_datetime.date.today.return_value = self.mocked_today + + allocation: Allocation = AllocationFactory(end_date=self.three_years_after_mocked_today) + + self.assertEqual(allocation.expires_in, days_in_three_years_excluding_leap_year) + + def test_end_date_in_four_years_returns_days_including_leap_day(self): + """Test that the expires_in method accounts for the extra day of a leap year.""" + days_in_four_years_including_leap_year = (365 * 4) + 1 + + with patch("coldfront.core.allocation.models.datetime") as mock_datetime: + mock_datetime.date.today.return_value = self.mocked_today + + allocation: Allocation = AllocationFactory(end_date=self.four_years_after_mocked_today) + + self.assertEqual(allocation.expires_in, days_in_four_years_including_leap_year) + + +class AllocationModelGetInformationTests(TestCase): + path_to_allocation_models_allocation_attribute_view_list: str = ( + "coldfront.core.allocation.models.ALLOCATION_ATTRIBUTE_VIEW_LIST" + ) + + def test_no_allocation_attributes_returns_empty_string(self): + """Test that the get_information method returns an empty string if there are no allocation attributes.""" + allocation: Allocation = AllocationFactory() + self.assertEqual(allocation.get_information, "") + + @patch(path_to_allocation_models_allocation_attribute_view_list, list()) + def test_attribute_type_not_in_view_list_returns_empty_string(self): + """Test that the get_information method returns an empty string if the attribute type is not in ALLOCATION_ATTRIBUTE_VIEW_LIST.""" + allocation: Allocation = AllocationFactory() + self.assertEqual(allocation.get_information, "") + + def test_attribute_value_is_zero_returns_100_percent_string(self): + allocation: Allocation = AllocationFactory() + allocation_attribute: AllocationAttribute = AllocationAttributeFactory(allocation=allocation, value=0) + allocation_attribute_usage = AllocationAttributeUsageFactory( + allocation_attribute=allocation_attribute, value=10 + ) + + allocation_attribute_type_name: str = allocation_attribute.allocation_attribute_type.name + allocation_attribute_usage_value: float = float(allocation_attribute_usage.value) + allocation_attribute_value: str = allocation_attribute.value + expected_percent = 100 + + expected_information: SafeString = format_html( + "{}: {}/{} ({} %)
", + allocation_attribute_type_name, + allocation_attribute_usage_value, + allocation_attribute_value, + expected_percent, + ) + + self.assertEqual(allocation.get_information, expected_information) + + def test_multiple_attributes_with_same_type_returns_combined_information(self): + """Test that the get_information method returns combined information for multiple attributes.""" + allocation: Allocation = AllocationFactory() + allocation_attribute_type = AllocationAttributeTypeFactory() + + allocation_attribute_1: AllocationAttribute = AllocationAttributeFactory( + allocation=allocation, allocation_attribute_type=allocation_attribute_type, value=100 + ) + allocation_attribute_2: AllocationAttribute = AllocationAttributeFactory( + allocation=allocation, allocation_attribute_type=allocation_attribute_type, value=1000 + ) + allocation_attribute_usage_1 = AllocationAttributeUsageFactory( + allocation_attribute=allocation_attribute_1, value=50 + ) + allocation_attribute_usage_2 = AllocationAttributeUsageFactory( + allocation_attribute=allocation_attribute_2, value=500 + ) + + percent_1 = ( + round((float(allocation_attribute_usage_1.value) / float(allocation_attribute_1.value)) * 10_000) / 100 + ) + percent_2 = ( + round((float(allocation_attribute_usage_2.value) / float(allocation_attribute_2.value)) * 10_000) / 100 + ) + + expected_information: SafeString = format_html( + "{}: {}/{} ({} %)
{}: {}/{} ({} %)
", + allocation_attribute_type.name, + float(allocation_attribute_usage_1.value), + allocation_attribute_1.value, + percent_1, + allocation_attribute_type.name, + float(allocation_attribute_usage_2.value), + allocation_attribute_2.value, + percent_2, + ) + + self.assertEqual(allocation.get_information, expected_information) diff --git a/coldfront/core/allocation/test_views.py b/coldfront/core/allocation/tests/test_views.py similarity index 100% rename from coldfront/core/allocation/test_views.py rename to coldfront/core/allocation/tests/test_views.py