diff --git a/docs/changes/465.new.rst b/docs/changes/465.new.rst new file mode 100644 index 00000000..aaa23d6e --- /dev/null +++ b/docs/changes/465.new.rst @@ -0,0 +1 @@ +Introduced serializers for cloning GPP targets and observations, enabling deserializing and serializing through the ToO form with proper validation of input data. \ No newline at end of file diff --git a/src/goats_tom/api_views/gpp/toos.py b/src/goats_tom/api_views/gpp/toos.py index 9641e397..8e580589 100644 --- a/src/goats_tom/api_views/gpp/toos.py +++ b/src/goats_tom/api_views/gpp/toos.py @@ -10,6 +10,8 @@ from gpp_client.api.enums import ObservationWorkflowState from gpp_client.api.input_types import ( BandBrightnessIntegratedInput, + CloneObservationInput, + CloneTargetInput, ElevationRangeInput, ExposureTimeModeInput, ObservationPropertiesInput, @@ -23,6 +25,8 @@ from goats_tom.serializers.gpp import ( BrightnessesSerializer, + CloneObservationSerializer, + CloneTargetSerializer, ElevationRangeSerializer, ExposureModeSerializer, InstrumentRegistry, @@ -74,6 +78,8 @@ def create(self, request: Request, *args, **kwargs) -> Response: # TODO: Format elevation range from request data. # TODO: Format instrument from request data. # TODO: Format source profile from request data. + # print(self._format_clone_observation_input(request.data)) + # print(self._format_clone_target_input(request.data)) return Response({"detail": "Not yet implemented."}) @@ -313,6 +319,52 @@ def _format_target_properties(self, data: dict[str, Any]) -> TargetPropertiesInp """ raise NotImplementedError + def _format_clone_observation_input( + self, data: dict[str, Any] + ) -> CloneObservationInput: + """Format the clone observation input from the request data. + + Parameters + ---------- + data : dict[str, Any] + The request data containing observation fields. + + Returns + ------- + CloneObservationInput + The formatted clone observation input. + """ + clone_observation = CloneObservationSerializer(data=data) + clone_observation.is_valid(raise_exception=True) + + observation_properties = ObservationPropertiesInput( + **clone_observation.validated_data + ) + + return CloneObservationInput( + observation_id=clone_observation.observation_id, set=observation_properties + ) + + def _format_clone_target_input(self, data: dict[str, Any]) -> CloneTargetInput: + """Format the target input from the request data. + + Parameters + ---------- + data : dict[str, Any] + The request data containing target fields. + + Returns + ------- + CloneTargetInput + The formatted clone target input. + """ + clone_target = CloneTargetSerializer(data=data) + clone_target.is_valid(raise_exception=True) + + target_properties = TargetPropertiesInput(**clone_target.validated_data) + + return CloneTargetInput(target_id=clone_target.target_id, set=target_properties) + def _get_workflow_state( self, client: GPPClient, observation_id: str ) -> dict[str, Any]: diff --git a/src/goats_tom/serializers/gpp/__init__.py b/src/goats_tom/serializers/gpp/__init__.py index fd58a061..724c8020 100644 --- a/src/goats_tom/serializers/gpp/__init__.py +++ b/src/goats_tom/serializers/gpp/__init__.py @@ -1,4 +1,6 @@ from .brightnesses import BrightnessesSerializer +from .clone_observation import CloneObservationSerializer +from .clone_target import CloneTargetSerializer from .elevation_range import ElevationRangeSerializer from .exposure_mode import ExposureModeSerializer from .instruments import InstrumentRegistry @@ -10,4 +12,6 @@ "ElevationRangeSerializer", "SourceProfileSerializer", "InstrumentRegistry", + "CloneTargetSerializer", + "CloneObservationSerializer", ] diff --git a/src/goats_tom/serializers/gpp/clone_observation.py b/src/goats_tom/serializers/gpp/clone_observation.py new file mode 100644 index 00000000..c27fb104 --- /dev/null +++ b/src/goats_tom/serializers/gpp/clone_observation.py @@ -0,0 +1,140 @@ +""" +Clone Observation Serializer for the GPP module. +""" + +__all__ = ["CloneObservationSerializer"] + +from typing import Any + +from gpp_client.api.enums import ( + CloudExtinctionPreset, + ImageQualityPreset, + PosAngleConstraintMode, + SkyBackground, + WaterVapor, +) +from rest_framework import serializers + +from .utils import normalize + + +class CloneObservationSerializer(serializers.Serializer): + """ + Serializer for cloning observation data. + + This serializer processes hidden input fields related to observation cloning, such + as observation ID, observing mode, and various constraints. + """ + + hiddenObservationIdInput = serializers.CharField( + required=False, allow_blank=True, allow_null=True + ) + hiddenObservingModeInput = serializers.CharField( + required=False, allow_blank=True, allow_null=True + ) + observerNotesTextarea = serializers.CharField( + required=False, allow_blank=True, allow_null=True + ) + imageQualitySelect = serializers.ChoiceField( + choices=[c.value for c in ImageQualityPreset], required=False, allow_blank=False + ) + cloudExtinctionSelect = serializers.ChoiceField( + choices=[c.value for c in CloudExtinctionPreset], + required=False, + allow_blank=False, + ) + skyBackgroundSelect = serializers.ChoiceField( + choices=[c.value for c in SkyBackground], required=False, allow_blank=False + ) + waterVaporSelect = serializers.ChoiceField( + choices=[c.value for c in WaterVapor], required=False, allow_blank=False + ) + posAngleConstraintModeSelect = serializers.ChoiceField( + choices=[c.value for c in PosAngleConstraintMode], + required=False, + allow_blank=False, + ) + posAngleConstraintAngleInput = serializers.FloatField( + required=False, allow_null=True, min_value=0.0, max_value=360.0 + ) + + def to_internal_value(self, data: dict[str, Any]) -> dict[str, Any]: + """ + Normalize blank strings to ``None`` before standard processing because this is + form data. + + Parameters + ---------- + data : dict[str, Any] + The input data from the form. + + Returns + ------- + dict[str, Any] + The normalized internal value dictionary. + """ + normalized_data = {key: normalize(value) for key, value in data.items()} + return super().to_internal_value(normalized_data) + + def validate(self, data: dict[str, Any]) -> dict[str, Any]: + """ + Perform cross-field validation and build the structured data for the clone + observation. + + Parameters + ---------- + data : dict[str, Any] + The validated data dictionary. + + Returns + ------- + dict[str, Any] + The validated data dictionary. + """ + # Assign observation ID and observing mode if provided. + self._observation_id = data.get("hiddenObservationIdInput") + self._observing_mode = data.get("hiddenObservingModeInput") + + mode = data.get("posAngleConstraintModeSelect") + angle = data.get("posAngleConstraintAngleInput") + + # Validate that angle is provided if mode requires it. + if mode in { + PosAngleConstraintMode.FIXED.value, + PosAngleConstraintMode.ALLOW_FLIP.value, + PosAngleConstraintMode.PARALLACTIC_OVERRIDE.value, + }: + if angle is None: + raise serializers.ValidationError( + { + "Position Angle Input": ( + "This angle is required for the selected mode." + ) + } + ) + + return { + "observerNotes": data.get("observerNotesTextarea"), + "constraintSet": { + "imageQuality": data.get("imageQualitySelect"), + "cloudExtinction": data.get("cloudExtinctionSelect"), + "skyBackground": data.get("skyBackgroundSelect"), + "waterVapor": data.get("waterVaporSelect"), + # Placeholder for other serializer field. + "elevationRange": None, + }, + "posAngleConstraint": { + "mode": mode, + "angle": {"degrees": angle}, + }, + # Placeholder for other serializer field. + "observingMode": None, + } + + @property + def observation_id(self) -> str | None: + return getattr(self, "_observation_id", None) + + @property + def observing_mode(self) -> str | None: + return getattr(self, "_observing_mode", None) diff --git a/src/goats_tom/serializers/gpp/clone_target.py b/src/goats_tom/serializers/gpp/clone_target.py new file mode 100644 index 00000000..5e82ee26 --- /dev/null +++ b/src/goats_tom/serializers/gpp/clone_target.py @@ -0,0 +1,92 @@ +""" +Clone Target Serializer for the GPP module. +""" + +__all__ = ["CloneTargetSerializer"] + +from typing import Any + +from rest_framework import serializers + +from .utils import normalize + + +class CloneTargetSerializer(serializers.Serializer): + """Serializer for cloning target data. + + This serializer processes hidden input fields related to target cloning, such as + target ID, radial velocity, parallax, and proper motion. + """ + + hiddenTargetIdInput = serializers.CharField( + required=False, allow_null=True, allow_blank=True + ) + radialVelocityInput = serializers.FloatField(required=False, allow_null=True) + parallaxInput = serializers.FloatField(required=False, allow_null=True) + uRaInput = serializers.FloatField(required=False, allow_null=True) + uDecInput = serializers.FloatField(required=False, allow_null=True) + + def to_internal_value(self, data: dict[str, Any]) -> dict[str, Any]: + """ + Normalize blank strings to ``None`` before standard processing because this is + form data. + + Parameters + ---------- + data : dict[str, Any] + The input data from the form. + + Returns + ------- + dict[str, Any] + The normalized internal value dictionary. + """ + normalized_data = {key: normalize(value) for key, value in data.items()} + return super().to_internal_value(normalized_data) + + def validate(self, data: dict[str, Any]) -> dict[str, Any]: + """ + Perform cross-field validation and build the structured data for the clone + target. + + Parameters + ---------- + data : dict[str, Any] + The validated data dictionary. + + Returns + ------- + dict[str, Any] + The validated data dictionary. + + Notes + ----- + - RA and Dec are set to dummy values as they are not modified via the ToO form. + - The epoch is set to a standard value of "J2000". + """ + # Assign target ID if provided. + self._target_id = data.get("hiddenTargetIdInput") + + return { + "sidereal": { + "radialVelocity": { + "kilometersPerSecond": data.get("radialVelocityInput") + }, + "parallax": {"milliarcseconds": data.get("parallaxInput")}, + "properMotion": { + "ra": {"milliarcsecondsPerYear": data.get("uRaInput")}, + "dec": {"milliarcsecondsPerYear": data.get("uDecInput")}, + }, + # RA and Dec are not modified via TOO form, set to dummy values. + "ra": {"degrees": None}, + "dec": {"degrees": None}, + # Use standard epoch. + "epoch": "J2000", + }, + # Placeholder for other serializer field. + "sourceProfile": None, + } + + @property + def target_id(self) -> str | None: + return getattr(self, "_target_id", None) diff --git a/tests/goats_tom/api_views/gpp/test_toos.py b/tests/goats_tom/api_views/gpp/test_toos.py index 4018101f..879fcfad 100644 --- a/tests/goats_tom/api_views/gpp/test_toos.py +++ b/tests/goats_tom/api_views/gpp/test_toos.py @@ -14,7 +14,7 @@ TargetPropertiesInput, SourceProfileInput ) -from rest_framework import status +from rest_framework import status, serializers from rest_framework.test import APIRequestFactory, force_authenticate from goats_tom.api_views import GPPTooViewSet @@ -193,10 +193,10 @@ def test_format_brightnesses_properties_invalid_data(self, mocker) -> None: "goats_tom.api_views.gpp.toos.BrightnessesSerializer" ) mock_serializer_instance = mock_serializer.return_value - mock_serializer_instance.is_valid.side_effect = ValueError("Invalid data") + mock_serializer_instance.is_valid.side_effect = serializers.ValidationError viewset = GPPTooViewSet() - with pytest.raises(ValueError, match="Invalid data"): + with pytest.raises(serializers.ValidationError): viewset._format_brightnesses_properties({"brightnesses": [{"band": "V"}]}) mock_serializer.assert_called_once_with(data={"brightnesses": [{"band": "V"}]}) @@ -240,10 +240,10 @@ def test_format_exposure_mode_properties_invalid_data(self, mocker) -> None: "goats_tom.api_views.gpp.toos.ExposureModeSerializer" ) mock_serializer_instance = mock_serializer.return_value - mock_serializer_instance.is_valid.side_effect = ValueError("Invalid data") + mock_serializer_instance.is_valid.side_effect = serializers.ValidationError viewset = GPPTooViewSet() - with pytest.raises(ValueError, match="Invalid data"): + with pytest.raises(serializers.ValidationError): viewset._format_exposure_mode_properties({"mode": "INVALID"}) mock_serializer.assert_called_once_with(data={"mode": "INVALID"}) @@ -317,10 +317,10 @@ def test_format_elevation_range_properties_invalid_data(self, mocker) -> None: "goats_tom.api_views.gpp.toos.ElevationRangeSerializer" ) mock_instance = mock_serializer.return_value - mock_instance.is_valid.side_effect = ValueError("Invalid data") + mock_instance.is_valid.side_effect = serializers.ValidationError viewset = GPPTooViewSet() - with pytest.raises(ValueError, match="Invalid data"): + with pytest.raises(serializers.ValidationError): viewset._format_elevation_range_properties({"haMinimumInput": "bad"}) mock_serializer.assert_called_once_with(data={"haMinimumInput": "bad"}) @@ -387,10 +387,10 @@ def test_format_instrument_properties_invalid_data(self, mocker) -> None: mock_serializer_class = mock_get_serializer.return_value mock_serializer_instance = mock_serializer_class.return_value - mock_serializer_instance.is_valid.side_effect = ValueError("Invalid data") + mock_serializer_instance.is_valid.side_effect = serializers.ValidationError viewset = GPPTooViewSet() - with pytest.raises(ValueError, match="Invalid data"): + with pytest.raises(serializers.ValidationError): viewset._format_instrument_properties({"field": "invalid"}) mock_get_serializer.assert_called_once_with({"field": "invalid"}) @@ -438,11 +438,102 @@ def test_format_source_profile_properties_invalid_data(self, mocker) -> None: "goats_tom.api_views.gpp.toos.SourceProfileSerializer" ) mock_serializer_instance = mock_serializer.return_value - mock_serializer_instance.is_valid.side_effect = ValueError("Invalid data") + mock_serializer_instance.is_valid.side_effect = serializers.ValidationError viewset = GPPTooViewSet() - with pytest.raises(ValueError, match="Invalid data"): + with pytest.raises(serializers.ValidationError): viewset._format_source_profile_properties({"profile": "INVALID"}) mock_serializer.assert_called_once_with(data={"profile": "INVALID"}) mock_serializer_instance.is_valid.assert_called_once_with(raise_exception=True) + + def test_format_clone_observation_input_valid_data(self, mocker) -> None: + """Test _format_clone_observation_input with valid data.""" + mock_serializer = mocker.patch( + "goats_tom.api_views.gpp.toos.CloneObservationSerializer" + ) + mock_serializer_instance = mock_serializer.return_value + mock_serializer_instance.is_valid.return_value = True + mock_serializer_instance.validated_data = {"field": "value"} + mock_serializer_instance.observation_id = "o-123" + + mock_observation_properties = mocker.patch( + "goats_tom.api_views.gpp.toos.ObservationPropertiesInput" + ) + mock_observation_properties_instance = mock_observation_properties.return_value + + mock_clone_observation_input = mocker.patch( + "goats_tom.api_views.gpp.toos.CloneObservationInput" + ) + + viewset = GPPTooViewSet() + result = viewset._format_clone_observation_input({"field": "value"}) + + assert result == mock_clone_observation_input.return_value + mock_serializer.assert_called_once_with(data={"field": "value"}) + mock_serializer_instance.is_valid.assert_called_once_with(raise_exception=True) + mock_observation_properties.assert_called_once_with(field="value") + mock_clone_observation_input.assert_called_once_with( + observation_id="o-123", set=mock_observation_properties_instance + ) + + + def test_format_clone_observation_input_invalid_data(self, mocker) -> None: + """Test _format_clone_observation_input with invalid data.""" + mock_serializer = mocker.patch( + "goats_tom.api_views.gpp.toos.CloneObservationSerializer" + ) + mock_serializer_instance = mock_serializer.return_value + mock_serializer_instance.is_valid.side_effect = serializers.ValidationError + + viewset = GPPTooViewSet() + with pytest.raises(serializers.ValidationError): + viewset._format_clone_observation_input({"field": "invalid"}) + + mock_serializer.assert_called_once_with(data={"field": "invalid"}) + mock_serializer_instance.is_valid.assert_called_once_with(raise_exception=True) + + def test_format_clone_target_input_valid_data(self, mocker) -> None: + """Test _format_clone_target_input with valid data.""" + mock_serializer = mocker.patch( + "goats_tom.api_views.gpp.toos.CloneTargetSerializer" + ) + mock_serializer_instance = mock_serializer.return_value + mock_serializer_instance.is_valid.return_value = True + mock_serializer_instance.validated_data = {"field": "value"} + mock_serializer_instance.target_id = "t-123" + + mock_target_properties = mocker.patch( + "goats_tom.api_views.gpp.toos.TargetPropertiesInput" + ) + mock_target_properties_instance = mock_target_properties.return_value + + mock_clone_target_input = mocker.patch( + "goats_tom.api_views.gpp.toos.CloneTargetInput" + ) + + viewset = GPPTooViewSet() + result = viewset._format_clone_target_input({"field": "value"}) + + assert result == mock_clone_target_input.return_value + mock_serializer.assert_called_once_with(data={"field": "value"}) + mock_serializer_instance.is_valid.assert_called_once_with(raise_exception=True) + mock_target_properties.assert_called_once_with(field="value") + mock_clone_target_input.assert_called_once_with( + target_id="t-123", set=mock_target_properties_instance + ) + + def test_format_clone_target_input_invalid_data(self, mocker) -> None: + """Test _format_clone_target_input with invalid data.""" + mock_serializer = mocker.patch( + "goats_tom.api_views.gpp.toos.CloneTargetSerializer" + ) + mock_serializer_instance = mock_serializer.return_value + mock_serializer_instance.is_valid.side_effect = serializers.ValidationError + + viewset = GPPTooViewSet() + with pytest.raises(serializers.ValidationError): + viewset._format_clone_target_input({"field": "invalid"}) + + mock_serializer.assert_called_once_with(data={"field": "invalid"}) + mock_serializer_instance.is_valid.assert_called_once_with(raise_exception=True) diff --git a/tests/goats_tom/serializers/gpp/test_clone_observation.py b/tests/goats_tom/serializers/gpp/test_clone_observation.py new file mode 100644 index 00000000..4f61c802 --- /dev/null +++ b/tests/goats_tom/serializers/gpp/test_clone_observation.py @@ -0,0 +1,171 @@ +import pytest +from rest_framework.exceptions import ValidationError + +from goats_tom.serializers.gpp import CloneObservationSerializer +from gpp_client.api.enums import ( + ImageQualityPreset, + CloudExtinctionPreset, + SkyBackground, + WaterVapor, + PosAngleConstraintMode, +) + + +@pytest.mark.parametrize( + "input_data, expected_output", + [ + # All fields present and valid, angle mode that requires angle. + ( + { + "hiddenObservationIdInput": "obs123", + "hiddenObservingModeInput": "GMOS", + "observerNotesTextarea": "Testing full input.", + "imageQualitySelect": ImageQualityPreset.ONE_POINT_FIVE.value, + "cloudExtinctionSelect": CloudExtinctionPreset.ONE_POINT_ZERO.value, + "skyBackgroundSelect": SkyBackground.DARK.value, + "waterVaporSelect": WaterVapor.DRY.value, + "posAngleConstraintModeSelect": PosAngleConstraintMode.FIXED.value, + "posAngleConstraintAngleInput": "180.0", + }, + { + "observerNotes": "Testing full input.", + "constraintSet": { + "imageQuality": ImageQualityPreset.ONE_POINT_FIVE.value, + "cloudExtinction": CloudExtinctionPreset.ONE_POINT_ZERO.value, + "skyBackground": SkyBackground.DARK.value, + "waterVapor": WaterVapor.DRY.value, + "elevationRange": None, + }, + "posAngleConstraint": { + "mode": PosAngleConstraintMode.FIXED.value, + "angle": {"degrees": 180.0}, + }, + "observingMode": None, + }, + ), + # Angle not required for selected mode. + ( + { + "imageQualitySelect": ImageQualityPreset.ONE_POINT_FIVE.value, + "cloudExtinctionSelect": CloudExtinctionPreset.ONE_POINT_ZERO.value, + "skyBackgroundSelect": SkyBackground.BRIGHT.value, + "waterVaporSelect": WaterVapor.DRY.value, + "posAngleConstraintModeSelect": PosAngleConstraintMode.UNBOUNDED.value, + }, + { + "observerNotes": None, + "constraintSet": { + "imageQuality": ImageQualityPreset.ONE_POINT_FIVE.value, + "cloudExtinction": CloudExtinctionPreset.ONE_POINT_ZERO.value, + "skyBackground": SkyBackground.BRIGHT.value, + "waterVapor": WaterVapor.DRY.value, + "elevationRange": None, + }, + "posAngleConstraint": { + "mode": PosAngleConstraintMode.UNBOUNDED.value, + "angle": {"degrees": None}, + }, + "observingMode": None, + }, + ), + # All optional fields blank strings. + ( + { + "hiddenObservationIdInput": "", + "hiddenObservingModeInput": "", + "observerNotesTextarea": "", + "imageQualitySelect": ImageQualityPreset.ONE_POINT_FIVE.value, + "cloudExtinctionSelect": CloudExtinctionPreset.ONE_POINT_ZERO.value, + "skyBackgroundSelect": SkyBackground.GRAY.value, + "waterVaporSelect": WaterVapor.DRY.value, + "posAngleConstraintModeSelect": PosAngleConstraintMode.AVERAGE_PARALLACTIC.value, + }, + { + "observerNotes": None, + "constraintSet": { + "imageQuality": ImageQualityPreset.ONE_POINT_FIVE.value, + "cloudExtinction": CloudExtinctionPreset.ONE_POINT_ZERO.value, + "skyBackground": SkyBackground.GRAY.value, + "waterVapor": WaterVapor.DRY.value, + "elevationRange": None, + }, + "posAngleConstraint": { + "mode": PosAngleConstraintMode.AVERAGE_PARALLACTIC.value, + "angle": {"degrees": None}, + }, + "observingMode": None, + }, + ), + ], +) +def test_valid_clone_observation_inputs(input_data, expected_output): + """Test valid combinations of CloneObservationSerializer input.""" + serializer = CloneObservationSerializer(data=input_data) + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data == expected_output + + +@pytest.mark.parametrize( + "input_data, expected_error_field, expected_message", + [ + # Angle required but missing. + ( + { + "posAngleConstraintModeSelect": PosAngleConstraintMode.FIXED.value, + "imageQualitySelect": ImageQualityPreset.ONE_POINT_FIVE.value, + "cloudExtinctionSelect": CloudExtinctionPreset.ONE_POINT_ZERO.value, + "skyBackgroundSelect": SkyBackground.DARK.value, + "waterVaporSelect": WaterVapor.DRY.value, + }, + "Position Angle Input", + "This angle is required for the selected mode.", + ), + # Angle required but null. + ( + { + "posAngleConstraintModeSelect": PosAngleConstraintMode.ALLOW_FLIP.value, + "posAngleConstraintAngleInput": None, + "imageQualitySelect": ImageQualityPreset.ONE_POINT_FIVE.value, + "cloudExtinctionSelect": CloudExtinctionPreset.ONE_POINT_ZERO.value, + "skyBackgroundSelect": SkyBackground.DARK.value, + "waterVaporSelect": WaterVapor.DRY.value, + }, + "Position Angle Input", + "This angle is required for the selected mode.", + ), + # Angle out of bounds. + ( + { + "posAngleConstraintModeSelect": PosAngleConstraintMode.FIXED.value, + "posAngleConstraintAngleInput": "999.0", + "imageQualitySelect": ImageQualityPreset.ONE_POINT_FIVE.value, + "cloudExtinctionSelect": CloudExtinctionPreset.ONE_POINT_ZERO.value, + "skyBackgroundSelect": SkyBackground.DARK.value, + "waterVaporSelect": WaterVapor.DRY.value, + }, + "posAngleConstraintAngleInput", + "Ensure this value is less than or equal to 360.0.", + ), + ( + { + "posAngleConstraintModeSelect": PosAngleConstraintMode.FIXED.value, + "posAngleConstraintAngleInput": "-10.0", + "imageQualitySelect": ImageQualityPreset.ONE_POINT_FIVE.value, + "cloudExtinctionSelect": CloudExtinctionPreset.ONE_POINT_ZERO.value, + "skyBackgroundSelect": SkyBackground.DARK.value, + "waterVaporSelect": WaterVapor.DRY.value, + }, + "posAngleConstraintAngleInput", + "Ensure this value is greater than or equal to 0.0.", + ), + ], +) +def test_invalid_clone_observation_inputs(input_data, expected_error_field, expected_message): + """Test invalid CloneObservationSerializer cases.""" + serializer = CloneObservationSerializer(data=input_data) + with pytest.raises(ValidationError) as excinfo: + serializer.is_valid(raise_exception=True) + + error_str = str(excinfo.value.detail) + assert expected_error_field in error_str + assert expected_message in error_str diff --git a/tests/goats_tom/serializers/gpp/test_clone_target.py b/tests/goats_tom/serializers/gpp/test_clone_target.py new file mode 100644 index 00000000..6810b127 --- /dev/null +++ b/tests/goats_tom/serializers/gpp/test_clone_target.py @@ -0,0 +1,136 @@ +import pytest +from rest_framework.exceptions import ValidationError + +from goats_tom.serializers.gpp import CloneTargetSerializer + + +@pytest.mark.parametrize( + "input_data, expected_output", + [ + # All fields provided and valid. + ( + { + "hiddenTargetIdInput": "TGT-123", + "radialVelocityInput": "12.5", + "parallaxInput": "3.14", + "uRaInput": "-1.5", + "uDecInput": "2.7", + }, + { + "sidereal": { + "radialVelocity": {"kilometersPerSecond": 12.5}, + "parallax": {"milliarcseconds": 3.14}, + "properMotion": { + "ra": {"milliarcsecondsPerYear": -1.5}, + "dec": {"milliarcsecondsPerYear": 2.7}, + }, + "ra": {"degrees": None}, + "dec": {"degrees": None}, + "epoch": "J2000", + }, + "sourceProfile": None, + }, + ), + # Only radial velocity. + ( + {"radialVelocityInput": "20.0"}, + { + "sidereal": { + "radialVelocity": {"kilometersPerSecond": 20.0}, + "parallax": {"milliarcseconds": None}, + "properMotion": { + "ra": {"milliarcsecondsPerYear": None}, + "dec": {"milliarcsecondsPerYear": None}, + }, + "ra": {"degrees": None}, + "dec": {"degrees": None}, + "epoch": "J2000", + }, + "sourceProfile": None, + }, + ), + # All optional fields blank strings. + ( + { + "hiddenTargetIdInput": "", + "radialVelocityInput": "", + "parallaxInput": "", + "uRaInput": "", + "uDecInput": "", + }, + { + "sidereal": { + "radialVelocity": {"kilometersPerSecond": None}, + "parallax": {"milliarcseconds": None}, + "properMotion": { + "ra": {"milliarcsecondsPerYear": None}, + "dec": {"milliarcsecondsPerYear": None}, + }, + "ra": {"degrees": None}, + "dec": {"degrees": None}, + "epoch": "J2000", + }, + "sourceProfile": None, + }, + ), + # No fields provided. + ( + {}, + { + "sidereal": { + "radialVelocity": {"kilometersPerSecond": None}, + "parallax": {"milliarcseconds": None}, + "properMotion": { + "ra": {"milliarcsecondsPerYear": None}, + "dec": {"milliarcsecondsPerYear": None}, + }, + "ra": {"degrees": None}, + "dec": {"degrees": None}, + "epoch": "J2000", + }, + "sourceProfile": None, + }, + ), + # Not used field provided. + ( + {"someUnusedField": "value"}, + { + "sidereal": { + "radialVelocity": {"kilometersPerSecond": None}, + "parallax": {"milliarcseconds": None}, + "properMotion": { + "ra": {"milliarcsecondsPerYear": None}, + "dec": {"milliarcsecondsPerYear": None}, + }, + "ra": {"degrees": None}, + "dec": {"degrees": None}, + "epoch": "J2000", + }, + "sourceProfile": None, + }, + ), + ], +) +def test_valid_clone_target_inputs(input_data, expected_output): + """Test valid CloneTargetSerializer cases with formdata string inputs.""" + serializer = CloneTargetSerializer(data=input_data) + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data == expected_output + + +@pytest.mark.parametrize( + "input_data, expected_error_field", + [ + ({"radialVelocityInput": "not_a_number"}, "radialVelocityInput"), + ({"parallaxInput": "NaNish"}, "parallaxInput"), + ({"uRaInput": "oops"}, "uRaInput"), + ({"uDecInput": "?"}, "uDecInput"), + ], +) +def test_invalid_clone_target_inputs(input_data, expected_error_field): + """Test invalid CloneTargetSerializer input where numeric coercion fails.""" + serializer = CloneTargetSerializer(data=input_data) + with pytest.raises(ValidationError) as excinfo: + serializer.is_valid(raise_exception=True) + + assert expected_error_field in excinfo.value.detail