"""Tests for custom schema methods and helpers.""" from datetime import UTC, datetime from typing import Any from unittest.mock import MagicMock, patch import pytest from freezegun import freeze_time from freezegun.api import FakeDatetime from marshmallow import exceptions from vectororder.constants import search from vectororder.models.schemas import JobStatus from vectororder.schemas import search_schema def date_range_fields_iterator(with_dest: bool = False) -> list[list[str]]: """Date range fields fixture. Args: with_dest (bool): If to include destination field in response. Returns: list: A list of [low, high] and optionally destination fields. """ result = [] for (low, high), dest in search_schema.DATE_RANGE_FIELDS: fields = [low, high] if with_dest: fields.append(dest) result.append(fields) return result # Note: this test assumes the configured VO types are 'delivery', 'meta_update' @pytest.mark.parametrize( "input_data, expected_result", ( # Empty data is allowed. ({}, {}), # Unknown fields are passed through. ({"foo": "bar"}, {"foo": "bar"}), # meta_update is True for search.VO_TYPE_META_UPDATE ({"meta_update": [search.VO_TYPE_META_UPDATE]}, {"meta_update": True}), # meta_update is False for search.VO_TYPE_DELIVERY ({"meta_update": [search.VO_TYPE_DELIVERY]}, {"meta_update": False}), # If all possible values are present - do not include the field. ({"meta_update": search.VO_TYPES}, {}), # Note: invalid input is checked by the schema field validators. ), ) def test_order_types_to_meta_update( input_data: dict[str, Any], expected_result: dict[str, Any] ) -> None: """Test post load hook that converts order types to meta_update.""" schema = search_schema.SearchPOSTSchema() result = schema._order_types_to_meta_update(input_data) assert result == expected_result @pytest.mark.parametrize("date_range_fields", date_range_fields_iterator()) @pytest.mark.parametrize( "input_values, should_be_valid", ( # 'from' date is less than or equal 'to' date. ((datetime(2000, 2, 2), datetime(2000, 2, 3)), True), ((datetime(2000, 2, 2, 2), datetime(2000, 2, 2, 3)), True), ( (datetime(2000, 2, 2, 2, 2), datetime(2000, 2, 2, 2, 3)), True, ), # If at least one of the values is not set - input is valid. ((None, None), True), ((datetime(2000, 2, 2), None), True), ((None, datetime(2000, 2, 2)), True), # 'from' date is not higher than 'to' date. ((datetime(2000, 2, 3), datetime(2000, 2, 2)), False), ((datetime(2000, 2, 2, 3), datetime(2000, 2, 2, 2)), False), ( (datetime(2000, 2, 2, 2, 3), datetime(2000, 2, 2, 2, 2)), False, ), ), ) def test_validate_dates( input_values: tuple[datetime, datetime], should_be_valid: bool, date_range_fields: tuple[str, str], ) -> None: """Test date ranges are validated.""" schema = search_schema.SearchPOSTSchema() input_data = { date_range_fields[0]: input_values[0], date_range_fields[1]: input_values[1], } if should_be_valid: schema.validate_dates(input_data) else: with pytest.raises(exceptions.ValidationError): schema.validate_dates(input_data) @pytest.mark.parametrize( "input_data, should_be_valid", ( ({"user_ids": [1, 2]}, True), ({"user_ids": [1, 2], "unknown_field": 1}, False), ({"unknown_field": 1}, False), ), ) def test_validate_unknown_fields( input_data: dict[str, Any], should_be_valid: bool ) -> None: """Test no unknown fields are allowed by the schema.""" schema = search_schema.SearchPOSTSchema() if should_be_valid: schema.validate_unknown_fields({}, original_data=input_data) else: with pytest.raises(exceptions.ValidationError): schema.validate_unknown_fields({}, original_data=input_data) @pytest.mark.parametrize( "date_range_fields", date_range_fields_iterator(with_dest=True) ) @pytest.mark.parametrize( "input_values, expected_result_values", [ # Date-only inputs (naive midnight): from anchors at Eastern midnight, # to extends to end of Eastern day. Jan/Feb = EST (UTC-5). ( (datetime(2000, 1, 1), datetime(2000, 2, 2)), ( datetime(2000, 1, 1, 5, 0, 0, tzinfo=UTC), datetime(2000, 2, 3, 4, 59, 59, 999999, tzinfo=UTC), ), ), # Only 'from' is present. ( (datetime(2000, 1, 1), None), (datetime(2000, 1, 1, 5, 0, 0, tzinfo=UTC), None), ), # Only 'to' is present. ( (None, datetime(2000, 2, 2)), (None, datetime(2000, 2, 3, 4, 59, 59, 999999, tzinfo=UTC)), ), # Explicit time on 'to': treated as Eastern, not extended to end of day. ( (None, datetime(2000, 2, 2, 10, 30)), (None, datetime(2000, 2, 2, 15, 30, tzinfo=UTC)), ), ], ) def test_to_date_ranges( input_values: tuple[datetime | None, datetime | None], expected_result_values: tuple[datetime | None, datetime | None], date_range_fields: list[str], ) -> None: """Test Eastern datetime inputs are converted to UTC datetime ranges.""" schema = search_schema.SearchPOSTSchema() # To make sure no other fields are removed from the input data. extra_fields = {"foo": datetime.now(UTC)} input_data = { date_range_fields[0]: input_values[0], date_range_fields[1]: input_values[1], } input_data.update(extra_fields) expected_data: dict[str, Any] = {date_range_fields[2]: expected_result_values} expected_data.update(extra_fields) with freeze_time("2018-01-01", tz_offset=0): result = schema._to_date_ranges(input_data) # Make sure data is modified inplace. assert input_data is result assert result == expected_data @pytest.mark.parametrize( "input_data, expected_result", ( ( # Query is not modified if no stuck statuses are passed in. {"store_id": [286], "user_id": [1, 2]}, {"store_id": [286], "user_id": [1, 2]}, ), ( # Regular statuses. { "store_id": [286], "status": [JobStatus.ENCODING.value, JobStatus.DELIVERING.value], }, { "store_id": [286], search.VO_STATUSES: ( {"status": [JobStatus.ENCODING.value, JobStatus.DELIVERING.value]}, ), }, ), ( # Stuck status. {"status": [JobStatus.DELIVERING_STUCK.value]}, { search.VO_STATUSES: ( { "status": [JobStatus.DELIVERING.value], "delivery_started": ( None, FakeDatetime(2017, 12, 31, 0, 0, 0, 0, tzinfo=UTC), ), }, ) }, ), ( # Regular, stuck statuses and other fields. { "store_id": [286], "status": [ JobStatus.ENCODING_STUCK.value, JobStatus.DELIVERING_STUCK.value, JobStatus.ENCODING.value, JobStatus.SYSTEM_CANCELLED.value, ], }, { "store_id": [286], search.VO_STATUSES: ( { "status": [JobStatus.ENCODING.value], "encoding_started": ( None, FakeDatetime(2017, 12, 31, 0, 0, 0, 0, tzinfo=UTC), ), }, { "status": [JobStatus.DELIVERING.value], "delivery_started": ( None, FakeDatetime(2017, 12, 31, 0, 0, 0, 0, tzinfo=UTC), ), }, { "status": [ JobStatus.ENCODING.value, JobStatus.SYSTEM_CANCELLED.value, ] }, ), }, ), ), ) def test_process_statuses( input_data: dict[str, list[int | str]], expected_result: dict[str, Any] ) -> None: """Test stuck statuses are converted to real statuses with dates.""" schema = search_schema.SearchPOSTSchema() with freeze_time("2018-01-01", tz_offset=0): result = schema._process_statuses(input_data) # Make sure data is modified inplace. assert result is input_data # Compare result to the expected one. assert result == expected_result @patch( "vectororder.schemas.search_schema.SearchPOSTSchema._process_statuses", return_value={"process stuck": "some result"}, ) @patch( "vectororder.schemas.search_schema.SearchPOSTSchema._to_date_ranges", return_value={"date range": "any result"}, ) @patch( "vectororder.schemas.search_schema.SearchPOSTSchema._order_types_to_meta_update", return_value={"meta update": "some result"}, ) def test_post_load( mock_order_types_to_meta_update: MagicMock, mock_to_date_ranges: MagicMock, mock_process_statuses: MagicMock, ) -> None: """Test post load method calls every private method in turn.""" input_data = {"foo": "bar"} schema = search_schema.SearchPOSTSchema() schema.post_load(input_data) assert mock_process_statuses.call_args == ((input_data,),) assert mock_to_date_ranges.call_args == (({"process stuck": "some result"},),) assert mock_order_types_to_meta_update.call_args == ( ({"date range": "any result"},), )