diff --git a/src/eligibility_signposting_api/common/date_util.py b/src/eligibility_signposting_api/common/date_util.py new file mode 100644 index 000000000..bc31fc9ad --- /dev/null +++ b/src/eligibility_signposting_api/common/date_util.py @@ -0,0 +1,55 @@ +import re +from collections.abc import Callable +from datetime import date, datetime, time +from zoneinfo import ZoneInfo + + +def _parse_with_format[T]( + value: str, + regex: str, + fmt: str, + error_info: tuple[str, str], + transform: Callable[[datetime], T], +) -> T: + """Shared logic for regex validation and datetime parsing.""" + label, expected_format = error_info + + if not re.fullmatch(regex, value): + msg = f"Invalid format: {value}. Must be {expected_format}." + raise ValueError(msg) + try: + dt = datetime.strptime(value, fmt) # noqa: DTZ007 + return transform(dt) + except ValueError as err: + msg = f"Invalid {label} value: {value}." + raise ValueError(msg) from err + + +def convert_from_uk_to_utc(value: datetime | date) -> datetime: + if isinstance(value, date) and not isinstance(value, datetime): + value = datetime.combine(value, time.min) + + uk = ZoneInfo("Europe/London") + utc = ZoneInfo("UTC") + + if value.tzinfo is None: + value = value.replace(tzinfo=uk) + return value.astimezone(utc) + + +def parse_date_yyyymmdd(v: str | date) -> date: + if isinstance(v, date): + return v + # Pass the last two strings as a single tuple inside parentheses + return _parse_with_format(str(v), r"\d{8}", "%Y%m%d", ("date", "YYYYMMDD"), lambda dt: dt.date()) + + +def parse_time_hhmmss(v: str | time | None) -> time | None: + if v is None: + return None + if isinstance(v, time): + return v + # Pass the last two strings as a single tuple inside parentheses + return _parse_with_format( + str(v).strip(), r"^\d{2}:\d{2}:\d{2}$", "%H:%M:%S", ("time", "HH:MM:SS"), lambda dt: dt.time() + ) diff --git a/src/eligibility_signposting_api/model/campaign_config.py b/src/eligibility_signposting_api/model/campaign_config.py index 2e9a282a6..d65483cdd 100644 --- a/src/eligibility_signposting_api/model/campaign_config.py +++ b/src/eligibility_signposting_api/model/campaign_config.py @@ -21,6 +21,7 @@ model_validator, ) +from eligibility_signposting_api.common.date_util import convert_from_uk_to_utc, parse_date_yyyymmdd, parse_time_hhmmss from eligibility_signposting_api.config.constants import ALLOWED_CONDITIONS, RULE_STOP_DEFAULT if typing.TYPE_CHECKING: # pragma: no cover @@ -294,8 +295,12 @@ class Iteration(BaseModel): id: IterationID = Field(..., alias="ID") version: IterationVersion = Field(..., alias="Version") name: IterationName = Field(..., alias="Name") - iteration_date: IterationDate = Field(..., alias="IterationDate") - iteration_time: IterationTime | None = Field(default=None, alias="IterationTime") + iteration_date: IterationDate = Field( + ..., alias="IterationDate", description="Iteration start date in Europe/London time Zone" + ) + iteration_time: IterationTime | None = Field( + default=None, alias="IterationTime", description="Iteration start time in Europe/London time Zone" + ) iteration_number: int | None = Field(None, alias="IterationNumber") approval_minimum: int | None = Field(None, alias="ApprovalMinimum") approval_maximum: int | None = Field(None, alias="ApprovalMaximum") @@ -320,12 +325,12 @@ def _link_parent_to_iteration_rules(self) -> typing.Self: @field_validator("iteration_date", mode="before") @classmethod def parse_dates(cls, v: str | date) -> date: - return DateUtil.parse_date_yyyymmdd(v) + return parse_date_yyyymmdd(v) @field_validator("iteration_time", mode="before") @classmethod def parse_times(cls, v: str | time) -> time | None: - return DateUtil.parse_time_hhmmss(v) + return parse_time_hhmmss(v) @field_serializer("iteration_date", when_used="always") @staticmethod @@ -343,7 +348,7 @@ def set_parent(self, parent: CampaignConfig) -> None: self._parent = parent @cached_property - def iteration_datetime(self) -> datetime: + def iteration_datetime_utc(self) -> datetime: if self.iteration_time: iteration_time = self.iteration_time elif self._parent: @@ -352,7 +357,7 @@ def iteration_datetime(self) -> datetime: msg = f"No iteration_time and no parent linked for iteration {self.id}" raise ValueError(msg) - return datetime.combine(self.iteration_date, iteration_time).replace(tzinfo=UTC) + return convert_from_uk_to_utc(datetime.combine(self.iteration_date, iteration_time)) def __str__(self) -> str: return json.dumps(self.model_dump(by_alias=True), indent=2) @@ -369,32 +374,43 @@ class CampaignConfig(BaseModel): reviewer: list[str] | None = Field(None, alias="Reviewer") iteration_frequency: Literal["X", "D", "W", "M", "Q", "A"] = Field(..., alias="IterationFrequency") iteration_type: Literal["A", "M", "S", "O"] = Field(..., alias="IterationType") - iteration_time: IterationTime = Field(default=IterationTime(time(0, 0, 0)), alias="IterationTime") + iteration_time: IterationTime = Field( + default=IterationTime(time(0, 0, 0)), + alias="IterationTime", + description="Default Iteration start time in Europe/London time Zone", + ) default_comms_routing: str | None = Field(None, alias="DefaultCommsRouting") - start_date: StartDate = Field(..., alias="StartDate") - end_date: EndDate = Field(..., alias="EndDate") + start_date: StartDate = Field(..., alias="StartDate", description="Campaign start date in Europe/London time Zone") + end_date: EndDate = Field(..., alias="EndDate", description="Campaign end date in Europe/London time Zone") approval_minimum: int | None = Field(None, alias="ApprovalMinimum") approval_maximum: int | None = Field(None, alias="ApprovalMaximum") iterations: list[Iteration] = Field(..., min_length=1, alias="Iterations") model_config = {"populate_by_name": True, "arbitrary_types_allowed": True, "extra": "ignore"} - @model_validator(mode="after") - def _link_parent_to_iterations(self) -> typing.Self: + def __init__(self, **data: dict[str, typing.Any]) -> None: + super().__init__(**data) + # Ensure each rule knows its parent iteration for iteration in self.iterations: iteration.set_parent(self) - return self + @cached_property + def start_date_utc(self) -> datetime: + return convert_from_uk_to_utc(datetime.combine(self.start_date, time.min)) + + @cached_property + def end_date_utc(self) -> datetime: + return convert_from_uk_to_utc(datetime.combine(self.end_date, time.min)) @field_validator("start_date", "end_date", mode="before") @classmethod def parse_dates(cls, v: str | date) -> date: - return DateUtil.parse_date_yyyymmdd(v) + return parse_date_yyyymmdd(v) @field_validator("iteration_time", mode="before") @classmethod def parse_times(cls, v: str | time) -> time | None: - return DateUtil.parse_time_hhmmss(v) + return parse_time_hhmmss(v) @field_serializer("start_date", "end_date", when_used="always") @staticmethod @@ -436,13 +452,14 @@ def check_no_overlapping_iterations(self) -> typing.Self: @cached_property def campaign_live(self) -> bool: today = datetime.now(tz=UTC).date() - return self.start_date <= today <= self.end_date + today_midnight = datetime.combine(today, time.min, tzinfo=UTC) + return self.start_date_utc <= today_midnight <= self.end_date_utc @cached_property def current_iteration(self) -> Iteration: now = datetime.now(tz=UTC) - iterations_by_date_descending = sorted(self.iterations, key=attrgetter("iteration_datetime"), reverse=True) - return next(i for i in iterations_by_date_descending if i.iteration_datetime <= now) + iterations_by_date_descending = sorted(self.iterations, key=attrgetter("iteration_datetime_utc"), reverse=True) + return next(i for i in iterations_by_date_descending if i.iteration_datetime_utc <= now) def __str__(self) -> str: return json.dumps(self.model_dump(by_alias=True), indent=2) diff --git a/src/eligibility_signposting_api/services/processors/campaign_evaluator.py b/src/eligibility_signposting_api/services/processors/campaign_evaluator.py index 248c1c967..de238d9ce 100644 --- a/src/eligibility_signposting_api/services/processors/campaign_evaluator.py +++ b/src/eligibility_signposting_api/services/processors/campaign_evaluator.py @@ -31,7 +31,7 @@ def get_campaign_with_latest_iteration(self, active_campaigns: list[CampaignConf for cc in active_campaigns: try: - valid_items.append((cc.current_iteration.iteration_datetime, cc)) + valid_items.append((cc.current_iteration.iteration_datetime_utc, cc)) except StopIteration: logger.info( "Skipping campaign ID %s as no active iteration was found.", @@ -42,19 +42,19 @@ def get_campaign_with_latest_iteration(self, active_campaigns: list[CampaignConf latest_campaign = None else: max_date_time = max(item[0] for item in valid_items) - cc_with_max_iteration_date: list[CampaignConfig] = [ + cc_with_max_iteration_date_time: list[CampaignConfig] = [ item[1] for item in valid_items if item[0] == max_date_time ] - if len(cc_with_max_iteration_date) > 1: + if len(cc_with_max_iteration_date_time) > 1: err_msg = ( - f"Ambiguous result: '{len(cc_with_max_iteration_date)}' active iterations " - f"for target {cc_with_max_iteration_date[0].target} " + f"Ambiguous result: '{len(cc_with_max_iteration_date_time)}' active iterations " + f"for target {cc_with_max_iteration_date_time[0].target} " f"found for datetime '{max_date_time}' " - f"across campaign(s) {[cc.id for cc in cc_with_max_iteration_date]}" + f"across campaign(s) {[cc.id for cc in cc_with_max_iteration_date_time]}" ) raise ValueError(err_msg) - latest_campaign = cc_with_max_iteration_date[0] + latest_campaign = cc_with_max_iteration_date_time[0] return latest_campaign diff --git a/src/rules_validation_api/app.py b/src/rules_validation_api/app.py index 0d990ff3c..0f6496d0e 100644 --- a/src/rules_validation_api/app.py +++ b/src/rules_validation_api/app.py @@ -93,10 +93,10 @@ def display_current_iteration(result: RulesValidation) -> None: sys.stdout.write( f"{YELLOW}Current active Iteration Number: {RESET}{GREEN}{current.iteration_number}{RESET}\n" ) - tz = current.iteration_datetime.tzinfo + tz = current.iteration_datetime_utc.tzinfo sys.stdout.write( f"{YELLOW}Current active Iteration's date&time: " - f"{RESET}{GREEN}{current.iteration_datetime} ({tz}){RESET}\n" + f"{RESET}{GREEN}{current.iteration_datetime_utc} ({tz}){RESET}\n" ) except StopIteration: sys.stdout.write(f"{YELLOW}No active iteration could be determined{RESET}\n") @@ -120,10 +120,10 @@ def display_current_iteration(result: RulesValidation) -> None: sys.stdout.write( f"{YELLOW}Next active Iteration Number: {RESET}{GREEN}{next_iteration.iteration_number}{RESET}\n" ) - tz = next_iteration.iteration_datetime.tzinfo + tz = next_iteration.iteration_datetime_utc.tzinfo sys.stdout.write( f"{YELLOW}Next active Iteration's date&time: " - f"{RESET}{GREEN}{next_iteration.iteration_datetime} ({tz}){RESET}\n" + f"{RESET}{GREEN}{next_iteration.iteration_datetime_utc} ({tz}){RESET}\n" ) except StopIteration: sys.stdout.write(f"{YELLOW}No next active iteration could be determined{RESET}\n") diff --git a/src/rules_validation_api/validators/campaign_config_validator.py b/src/rules_validation_api/validators/campaign_config_validator.py index 8cf05faf1..eac0d5c42 100644 --- a/src/rules_validation_api/validators/campaign_config_validator.py +++ b/src/rules_validation_api/validators/campaign_config_validator.py @@ -46,15 +46,19 @@ def validate_iterations_have_unique_number(self) -> typing.Self: @model_validator(mode="after") def validate_campaign_has_iteration_within_schedule(self) -> typing.Self: errors: list[str] = [] - iterations_by_date = sorted(self.iterations, key=attrgetter("iteration_date")) + for iteration in self.iterations: + if iteration._parent is None: # noqa : SLF001 + iteration.set_parent(self) + + iterations_by_date = sorted(self.iterations, key=attrgetter("iteration_datetime_utc")) for idx, iteration in enumerate(iterations_by_date): - if iteration.iteration_date < self.start_date: + if iteration.iteration_datetime_utc < self.start_date_utc: errors.append( f"CampaignConfig.Iterations.{idx}.IterationDate : " f"Starts before campaign start date {self.start_date} [type=invalid]" ) - if iteration.iteration_date > self.end_date: + if iteration.iteration_datetime_utc > self.end_date_utc: errors.append( f"CampaignConfig.Iterations.{idx}.IterationDate : " f"Starts after campaign end date {self.end_date} [type=invalid]" diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index a7ce2aea3..8d9e8d7c3 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -1,12 +1,14 @@ import json from datetime import UTC, date, datetime, timedelta from http import HTTPStatus +from zoneinfo import ZoneInfo import pytest from botocore.client import BaseClient from brunns.matchers.data import json_matching as is_json_that from brunns.matchers.werkzeug import is_werkzeug_response as is_response from flask.testing import FlaskClient +from freezegun import freeze_time from hamcrest import ( assert_that, contains_exactly, @@ -1446,7 +1448,7 @@ def test_if_cc_with_latest_active_iteration_is_chosen_if_exists_multiple_campaig else: assert_that(len(audit_data["response"]["condition"]), equal_to(0)) - def test_if_multiple_active_iterations_with_same_iteration_datetime_for_the_same_target_throws_internal_error( # noqa: PLR0913 + def test_if_multiple_active_iterations_with_same_iteration_date_default_time_for_same_target_throws_internal_error( # noqa: PLR0913 self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, @@ -1541,3 +1543,246 @@ def test_if_multiple_active_iterations_with_same_iteration_datetime_for_the_same assert any(err_msg in message for message in caplog.messages), ( f"Expected log message not found. Logged messages: {caplog.messages}" ) + + def test_if_multiple_active_iterations_with_same_iteration_date_and_time_for_same_target_throws_internal_error( # noqa: PLR0913 + self, + client: FlaskClient, + persisted_person_pc_sw19: NHSNumber, + s3_client: BaseClient, + consumer_mapping_bucket: BucketName, + rules_bucket: BucketName, + secretsmanager_client: BaseClient, # noqa: ARG002 + caplog, + ): + # Given + consumer_id = "consumer-n3bs-jo4hn-ce4na" + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), UNIQUE_CONSUMER_HEADER: consumer_id} + now_london = datetime.now(ZoneInfo("Europe/London")) + now_utc = now_london.astimezone(UTC) + previous_day = yesterday() + + ## Campaign config 1 + campaign_1 = rule.RawCampaignConfigFactory.build( + id="RSV_campaign_id_1", + target="RSV", + start_date=previous_day, + type="V", + iterations=[ + rule.IterationFactory.build(iteration_date=previous_day), + rule.IterationFactory.build(iteration_date=tomorrow()), + ], + ) + + campaign_1_json = campaign_1.model_dump(by_alias=True) + + iteration_date_1 = now_london.strftime("%Y%m%d") + iteration_time_1 = now_london.strftime("%H:%M:%S") + iteration_time_1_after_20m = (now_london + timedelta(minutes=20)).strftime("%H:%M:%S") + campaign_1_json["Iterations"][0]["IterationDate"] = iteration_date_1 + campaign_1_json["Iterations"][0]["IterationTime"] = iteration_time_1 + campaign_1_json["Iterations"][1]["IterationDate"] = iteration_date_1 + campaign_1_json["Iterations"][1]["IterationTime"] = iteration_time_1_after_20m + + ## Campaign config 2 + campaign_2 = rule.RawCampaignConfigFactory.build( + id="RSV_campaign_id_2", + target="RSV", + start_date=previous_day, + type="V", + iterations=[ + rule.IterationFactory.build(iteration_date=previous_day), + rule.IterationFactory.build(iteration_date=tomorrow()), + ], + ) + + campaign_2_json = campaign_2.model_dump(by_alias=True) + iteration_date_2 = now_london.strftime("%Y%m%d") + iteration_time_2 = now_london.strftime("%H:%M:%S") + iteration_time_2_after_20m = (now_london + timedelta(minutes=20)).strftime("%H:%M:%S") + campaign_2_json["Iterations"][0]["IterationDate"] = iteration_date_2 + campaign_2_json["Iterations"][0]["IterationTime"] = iteration_time_2 + campaign_2_json["Iterations"][1]["IterationDate"] = iteration_date_2 + campaign_2_json["Iterations"][1]["IterationTime"] = iteration_time_2_after_20m + + # Upload to Campaign config bucket + for campaign in [campaign_1_json, campaign_2_json]: + campaign_id = campaign["ID"] + s3_client.put_object( + Bucket=rules_bucket, + Key=f"{campaign_id}.json", + Body=json.dumps({"CampaignConfig": campaign}), + ContentType="application/json", + ) + + # Upload Consumer Mapping Data + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping_config.json", + Body=json.dumps( + { + consumer_id: [ + {"CampaignConfigID": "RSV_campaign_id_1"}, + {"CampaignConfigID": "RSV_campaign_id_2"}, + ], + } + ), + ContentType="application/json", + ) + + # When + response = client.get(f"/patient-check/{persisted_person_pc_sw19}", headers=headers) + + # Then + assert_that( + response, + is_response() + .with_status_code(HTTPStatus.INTERNAL_SERVER_ERROR) + .with_headers(has_entries({"Content-Type": "application/fhir+json"})) + .and_text( + is_json_that( + has_entries( + resourceType="OperationOutcome", + issue=contains_exactly( + has_entries( + severity="error", + code="processing", + diagnostics="An unexpected error occurred.", + details={ + "coding": [ + { + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "INTERNAL_SERVER_ERROR", + "display": "An unexpected internal server error occurred.", + } + ] + }, + ) + ), + ) + ) + ), + ) + err_msg = ( + "Ambiguous result: '2' active iterations " + "for target RSV " + f"found for datetime '{now_utc.strftime('%Y-%m-%d')} {iteration_time_1}+00:00' " + "across campaign(s) ['RSV_campaign_id_1', 'RSV_campaign_id_2']" + ) + assert any(err_msg in message for message in caplog.messages), ( + f"Expected log message not found. Logged messages: {caplog.messages}" + ) + + @freeze_time("2025-08-08 00:00:00+01:00") # 2025-08-08 00:00 BST + def test_iteration_selection_by_datetime_with_multiple_campaigns_same_target( # noqa: PLR0913 + self, + client: FlaskClient, + persisted_person_pc_sw19: NHSNumber, + s3_client: BaseClient, + consumer_mapping_bucket: BucketName, + rules_bucket: BucketName, + audit_bucket: BucketName, + secretsmanager_client: BaseClient, # noqa: ARG002 + ): + # Given + consumer_id = "consumer-n3bs-jo4hn-ce4na" + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), UNIQUE_CONSUMER_HEADER: consumer_id} + start_date = datetime(2025, 8, 6, tzinfo=ZoneInfo("Europe/London")).date() + current_datetime = datetime(2025, 8, 8, tzinfo=ZoneInfo("Europe/London")) + next_day_datetime = current_datetime + timedelta(days=1) + + ## Campaign config 1 + campaign_1 = rule.RawCampaignConfigFactory.build( + id="RSV_campaign_id_1", + target="RSV", + start_date=start_date, + type="V", + iterations=[ + rule.IterationFactory.build( + iteration_date=start_date, iteration_rules=[rule.PostcodeSuppressionRuleFactory.build()] + ), + rule.IterationFactory.build( + id="current_active_iteration_id", + iteration_date=current_datetime.date(), + iteration_rules=[rule.PostcodeSuppressionRuleFactory.build()], + ), + ], + ) + + campaign_1_json = campaign_1.model_dump(by_alias=True) + + iteration_date_a = current_datetime.strftime("%Y%m%d") + iteration_date_b = current_datetime.strftime("%Y%m%d") + iteration_time_a = current_datetime.strftime("%H:%M:%S") + iteration_time_b_after_30m = (current_datetime + timedelta(minutes=30)).strftime("%H:%M:%S") + campaign_1_json["Iterations"][0]["IterationDate"] = iteration_date_b + campaign_1_json["Iterations"][0]["IterationTime"] = iteration_time_b_after_30m + campaign_1_json["Iterations"][1]["IterationDate"] = iteration_date_a + campaign_1_json["Iterations"][1]["IterationTime"] = iteration_time_a + + ## Campaign config 2 + campaign_2 = rule.RawCampaignConfigFactory.build( + id="RSV_campaign_id_2", + target="RSV", + start_date=start_date, + type="V", + iterations=[ + rule.IterationFactory.build( + iteration_date=start_date, iteration_rules=[rule.PostcodeSuppressionRuleFactory.build()] + ), + rule.IterationFactory.build( + iteration_date=current_datetime.date(), + iteration_rules=[rule.PostcodeSuppressionRuleFactory.build()], + ), + ], + ) + + campaign_2_json = campaign_2.model_dump(by_alias=True) + iteration_date_c = current_datetime.strftime("%Y%m%d") + iteration_time_c_after_20m = (current_datetime + timedelta(minutes=20)).strftime("%H:%M:%S") + iteration_date_d = next_day_datetime.strftime("%Y%m%d") + iteration_time_d = next_day_datetime.strftime("%H:%M:%S") + campaign_2_json["Iterations"][0]["IterationDate"] = iteration_date_c + campaign_2_json["Iterations"][0]["IterationTime"] = iteration_time_c_after_20m + campaign_2_json["Iterations"][1]["IterationDate"] = iteration_date_d + campaign_2_json["Iterations"][1]["IterationTime"] = iteration_time_d + + # Upload to Campaign config bucket + for campaign in [campaign_1_json, campaign_2_json]: + campaign_id = campaign["ID"] + s3_client.put_object( + Bucket=rules_bucket, + Key=f"{campaign_id}.json", + Body=json.dumps({"CampaignConfig": campaign}), + ContentType="application/json", + ) + + # Upload Consumer Mapping Data + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping_config.json", + Body=json.dumps( + { + consumer_id: [ + {"CampaignConfigID": "RSV_campaign_id_1"}, + {"CampaignConfigID": "RSV_campaign_id_2"}, + ], + } + ), + ContentType="application/json", + ) + + # When + response = client.get(f"/patient-check/{persisted_person_pc_sw19}", headers=headers) + + # Then + assert_that(response, is_response().with_status_code(HTTPStatus.OK)) + + objects = s3_client.list_objects_v2(Bucket=audit_bucket).get("Contents", []) + object_keys = [obj["Key"] for obj in objects] + latest_key = sorted(object_keys)[-1] + audit_data = json.loads(s3_client.get_object(Bucket=audit_bucket, Key=latest_key)["Body"].read()) + + # Then + assert_that(len(audit_data["response"]["condition"]), equal_to(1)) + assert_that(audit_data["response"]["condition"][0].get("campaignId"), equal_to("RSV_campaign_id_1")) + assert_that(audit_data["response"]["condition"][0].get("iterationId"), equal_to("current_active_iteration_id")) diff --git a/tests/unit/validation/conftest.py b/tests/unit/validation/conftest.py index c5f676407..e1866816a 100644 --- a/tests/unit/validation/conftest.py +++ b/tests/unit/validation/conftest.py @@ -12,7 +12,7 @@ def valid_campaign_config_with_only_mandatory_fields(): "IterationFrequency": "M", "IterationType": "A", "StartDate": "20250101", - "EndDate": "20250331", + "EndDate": "20260331", "Iterations": [ { "ID": "ITER001", diff --git a/tests/unit/validation/test_app.py b/tests/unit/validation/test_app.py index 8fba4d977..997aedd6d 100644 --- a/tests/unit/validation/test_app.py +++ b/tests/unit/validation/test_app.py @@ -136,7 +136,7 @@ def test_next_iteration_exists(): next_mock = Mock() next_mock.iteration_number = 8 next_mock.iteration_date = today + timedelta(days=5) - next_mock.iteration_datetime = datetime.combine( + next_mock.iteration_datetime_utc = datetime.combine( next_mock.iteration_date, datetime.min.time(), tzinfo=UTC, diff --git a/tests/unit/validation/test_campaign_config_validator.py b/tests/unit/validation/test_campaign_config_validator.py index 8efc725bf..8191e39df 100644 --- a/tests/unit/validation/test_campaign_config_validator.py +++ b/tests/unit/validation/test_campaign_config_validator.py @@ -322,16 +322,106 @@ def test_approval_minimum_greater_than_approval_maximum_is_invalid( data["ApprovalMaximum"] = approval_max CampaignConfigValidation(**data) - @freeze_time("2026-03-30 01:00:00") + @freeze_time("2026-06-01 00:05:00") # BST + def test_campaign_live_during_bst_transition(self, valid_campaign_config_with_only_mandatory_fields): + data = valid_campaign_config_with_only_mandatory_fields.copy() + # Align Campaign Dates + data["StartDate"] = "20260601" + data["EndDate"] = "20260630" + + # Fix Iterations to be within June 2026 + for i, iteration in enumerate(data["Iterations"]): + iteration["IterationDate"] = f"202606{10 + i}" + + model = CampaignConfigValidation(**data) + assert model.campaign_live is True + + @freeze_time("2026-01-01 00:05:00") # GMT + def test_campaign_live_during_gmt(self, valid_campaign_config_with_only_mandatory_fields): + data = valid_campaign_config_with_only_mandatory_fields.copy() + data["StartDate"] = "20260101" + data["EndDate"] = "20260131" + + for i, iteration in enumerate(data["Iterations"]): + iteration["IterationDate"] = f"202601{10 + i}" + + model = CampaignConfigValidation(**data) + assert model.campaign_live is True + + def test_iteration_datetime_utc_conversion(self, valid_campaign_config_with_only_mandatory_fields): + data = valid_campaign_config_with_only_mandatory_fields.copy() + data["IterationTime"] = "09:00:00" + + # Test Summer (BST) - Ensure Campaign covers July + data["StartDate"] = "20260701" + data["EndDate"] = "20260731" + data["Iterations"] = [data["Iterations"][0]] # Simplify to 1 iteration for this test + data["Iterations"][0]["IterationDate"] = "20260701" + + model_summer = CampaignConfigValidation(**data) + assert model_summer.iterations[0].iteration_datetime_utc.hour == 8 # noqa : PLR2004 + + # Test Winter (GMT) - Ensure Campaign covers January + data["StartDate"] = "20260101" + data["EndDate"] = "20260131" + data["Iterations"][0]["IterationDate"] = "20260101" + + model_winter = CampaignConfigValidation(**data) + assert model_winter.iterations[0].iteration_datetime_utc.hour == 9 # noqa : PLR2004 + + @freeze_time("2026-05-31 22:59:59") # 1 second before BST Midnight + def test_campaign_not_live_yet_bst(self, valid_campaign_config_with_only_mandatory_fields): + data = valid_campaign_config_with_only_mandatory_fields.copy() + data["StartDate"] = "20260601" + data["EndDate"] = "20260630" + + for i, iteration in enumerate(data["Iterations"]): + iteration["IterationDate"] = f"202606{10 + i}" + + model = CampaignConfigValidation(**data) + assert model.campaign_live is False + + @freeze_time("2026-03-29 00:59:59") # 1 second before BST Midnight + def test_get_current_iteration_1sec_before_bst(self, valid_campaign_config_with_only_mandatory_fields): + data = valid_campaign_config_with_only_mandatory_fields.copy() + data["StartDate"] = "20260301" + data["EndDate"] = "20260630" + iteration = data["Iterations"][0] + iteration["IterationDate"] = "20260329" + iteration["IterationTime"] = "01:00:00" + iteration = data["Iterations"][1] + iteration["IterationDate"] = "20260331" + + model = CampaignConfigValidation(**data) + + with pytest.raises(StopIteration): + assert model.current_iteration + + @freeze_time("2026-03-29 01:00:00") # Just after bst + def test_get_current_iteration_just_after_bst(self, valid_campaign_config_with_only_mandatory_fields): + data = valid_campaign_config_with_only_mandatory_fields.copy() + data["StartDate"] = "20260301" + data["EndDate"] = "20260630" + iteration = data["Iterations"][0] + iteration["IterationDate"] = "20260329" + iteration["IterationTime"] = "01:00:00" + iteration = data["Iterations"][1] + iteration["IterationDate"] = "20260331" + + model = CampaignConfigValidation(**data) + + assert model.current_iteration + + @freeze_time("2026-03-25 01:00:03") # using GMT for simplicity def test_get_current_iteration_by_iteration_date_time(self, valid_campaign_config_with_only_mandatory_fields): data = valid_campaign_config_with_only_mandatory_fields.copy() data["StartDate"] = "20260301" data["EndDate"] = "20260630" iteration_1 = data["Iterations"][0] - iteration_1["IterationDate"] = "20260329" + iteration_1["IterationDate"] = "20260325" iteration_1["IterationTime"] = "01:00:00" iteration_2 = data["Iterations"][1] - iteration_2["IterationDate"] = "20260329" + iteration_2["IterationDate"] = "20260325" iteration_2["IterationTime"] = "01:00:02" model = CampaignConfigValidation(**data) @@ -340,4 +430,4 @@ def test_get_current_iteration_by_iteration_date_time(self, valid_campaign_confi iteration_2["IterationDate"] + iteration_2["IterationTime"], "%Y%m%d%H:%M:%S" ).replace(tzinfo=UTC) - assert model.current_iteration.iteration_datetime == expected + assert model.current_iteration.iteration_datetime_utc == expected diff --git a/tests/unit/validation/test_iteration_validator.py b/tests/unit/validation/test_iteration_validator.py index 0c65e50cf..eaa953044 100644 --- a/tests/unit/validation/test_iteration_validator.py +++ b/tests/unit/validation/test_iteration_validator.py @@ -516,30 +516,41 @@ def test_invalid_iteration_collects_errors_if_iteration_rules_have_invalid_data( assert "AttributeName must be set" in errors[1]["msg"] @pytest.mark.parametrize( - ("iteration_time_input", "default_time_iteration_input", "expected_date_time"), + ("iteration_time_input", "default_time_iteration_input", "iteration_date", "expected_date_time"), [ + # GMT # Case 1: Iteration time overrides default - ("14:30:00", "09:00:00", datetime(2025, 1, 2, 14, 30, 0, tzinfo=UTC)), + ("14:30:00", "09:00:00", "20250102", datetime(2025, 1, 2, 14, 30, 0, tzinfo=UTC)), # Case 2: Iteration time is missing, so it uses campaign config iteration_time - (None, "09:00:00", datetime(2025, 1, 2, 9, 0, 0, tzinfo=UTC)), + (None, "09:00:00", "20250102", datetime(2025, 1, 2, 9, 0, 0, tzinfo=UTC)), # Case 3: Both are the same - ("10:00:00", "10:00:00", datetime(2025, 1, 2, 10, 0, 0, tzinfo=UTC)), + ("10:00:00", "10:00:00", "20250102", datetime(2025, 1, 2, 10, 0, 0, tzinfo=UTC)), # Case 4: Both are None, falls back to default value (12 AM) in campaign config iteration_time - (None, None, datetime(2025, 1, 2, 0, 0, 0, tzinfo=UTC)), + (None, None, "20250102", datetime(2025, 1, 2, 0, 0, 0, tzinfo=UTC)), + # BST + # Case 1: Iteration time overrides default + ("14:30:00", "09:00:00", "20250802", datetime(2025, 8, 2, 13, 30, 0, tzinfo=UTC)), + # Case 2: Iteration time is missing, so it uses campaign config iteration_time + (None, "09:00:00", "20250802", datetime(2025, 8, 2, 8, 0, 0, tzinfo=UTC)), + # Case 3: Both are the same + ("10:00:00", "10:00:00", "20250802", datetime(2025, 8, 2, 9, 0, 0, tzinfo=UTC)), + # Case 4: Both are None, falls back to default value (12 AM) in campaign config iteration_time + (None, None, "20250802", datetime(2025, 8, 1, 23, 0, 0, tzinfo=UTC)), ], ) - def test_iteration_full_datetime_validation( + def test_iteration_full_datetime_validation( # noqa : PLR0913 self, valid_campaign_config_with_only_mandatory_fields, valid_iteration_with_only_mandatory_fields, iteration_time_input, default_time_iteration_input, + iteration_date, expected_date_time, ): # Given iteration_data = valid_iteration_with_only_mandatory_fields.copy() iteration_data["IterationTime"] = iteration_time_input - iteration_data["IterationDate"] = "20250102" # between campaign start_date and end_date + iteration_data["IterationDate"] = iteration_date # between campaign start_date and end_date data = valid_campaign_config_with_only_mandatory_fields.copy() @@ -552,7 +563,7 @@ def test_iteration_full_datetime_validation( config = CampaignConfigValidation(**data) # Then - result = config.iterations[0].iteration_datetime + result = config.iterations[0].iteration_datetime_utc assert result == expected_date_time, ( f"Failed! Input: {iteration_time_input}, Default: {default_time_iteration_input}. "