diff --git a/spoolman/extra_fields.py b/spoolman/extra_fields.py index 83e5c18..ad32284 100644 --- a/spoolman/extra_fields.py +++ b/spoolman/extra_fields.py @@ -38,7 +38,11 @@ class ExtraFieldParameters(BaseModel): unit: Optional[str] = Field(None, description="Unit of the value", min_length=1, max_length=16) field_type: ExtraFieldType = Field(description="Type of the field") default_value: Optional[str] = Field(None, description="Default value of the field") - choices: Optional[list[str]] = Field(None, description="Choices for the field, only for field type choice") + choices: Optional[list[str]] = Field( + None, + description="Choices for the field, only for field type choice", + min_items=1, + ) multi_choice: Optional[bool] = Field(None, description="Whether multiple choices can be selected") @@ -100,18 +104,12 @@ def validate_extra_field_value(field: ExtraFieldParameters, value: str) -> None: raise ValueError(f"Unknown field type {field.field_type}.") -def validate_extra_field(field: ExtraFieldParameters) -> None: # noqa: C901 +def validate_extra_field(field: ExtraFieldParameters) -> None: """Validate an extra field.""" # Validate choices exist if field type is choice if field.field_type == ExtraFieldType.choice: if field.choices is None: raise ValueError("Choices must be set for field type choice.") - if not isinstance(field.choices, list): - raise ValueError("Choices must be a list.") - if not all(isinstance(choice, str) for choice in field.choices): - raise ValueError("Choices must be a list of strings.") - if len(field.choices) == 0: - raise ValueError("Choices must not be empty.") if field.multi_choice is None: raise ValueError("Multi choice must be set for field type choice.") else: diff --git a/tests_integration/tests/conftest.py b/tests_integration/tests/conftest.py index a87de9f..f8d537e 100644 --- a/tests_integration/tests/conftest.py +++ b/tests_integration/tests/conftest.py @@ -240,3 +240,54 @@ def length_from_weight(*, weight: float, diameter: float, density: float) -> flo volume_cm3 = weight / density volume_mm3 = volume_cm3 * 1000 return volume_mm3 / (math.pi * (diameter / 2) ** 2) + + +def assert_dicts_compatible(actual: Any, expected: Any, path: str = "") -> None: # noqa: ANN401 + """Assert that two dictionaries are compatible for unit testing a REST API. + + Args: + actual (dict): The actual dictionary. + expected (dict): The expected dictionary. + path (str): The path to the current level in the dictionary (used for error messages). + + Raises: + AssertionError: If dictionaries are not compatible. + """ + # Check if both inputs are dictionaries + if not (isinstance(actual, dict) and isinstance(expected, dict)): + raise TypeError(f"At {path}: Actual and expected values must be dictionaries.") + + # Check if actual dictionary contains all keys of the expected dictionary + missing_keys = [key for key in expected if key not in actual] + if missing_keys: + raise AssertionError(f"At {path}: Missing keys in actual dictionary: {missing_keys}") + + # Recursively check values if the corresponding keys exist + for key, expected_value in expected.items(): + actual_value = actual[key] + subpath = f"{path}.{key}" if path else key # Update the path for the current level + + # If the value is another dictionary, recurse into it + if isinstance(expected_value, dict): + assert_dicts_compatible(actual_value, expected_value, path=subpath) + elif actual_value != expected_value: # Check if values are equal + raise AssertionError( + f"At {subpath}: Values do not match. Expected: {expected_value}, Actual: {actual_value}", + ) + + +def assert_lists_compatible(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]], sort_key: str = "id") -> None: + """Compare two lists of items where the order of the items is not guaranteed.""" + a_sorted = sorted(a, key=lambda x: x[sort_key]) + b_sorted = sorted(b, key=lambda x: x[sort_key]) + if len(a_sorted) != len(b_sorted): + pytest.fail(f"Lists have different lengths: {len(a_sorted)} != {len(b_sorted)}") + + for a_filament, b_filament in zip(a_sorted, b_sorted): + assert_dicts_compatible(a_filament, b_filament) + + +def assert_httpx_success(response: httpx.Response) -> None: + """Assert that a response is successful.""" + if not response.is_success: + pytest.fail(f"Request failed: {response.status_code} {response.text}") diff --git a/tests_integration/tests/fields/__init__.py b/tests_integration/tests/fields/__init__.py new file mode 100644 index 0000000..a59459b --- /dev/null +++ b/tests_integration/tests/fields/__init__.py @@ -0,0 +1 @@ +"""Integration tests for the custom extra fields system.""" diff --git a/tests_integration/tests/fields/test_create.py b/tests_integration/tests/fields/test_create.py new file mode 100644 index 0000000..83b1e13 --- /dev/null +++ b/tests_integration/tests/fields/test_create.py @@ -0,0 +1,262 @@ +"""Integration tests for the custom extra fields system.""" + +import json +from datetime import datetime, timezone + +import httpx +import pytest + +from ..conftest import assert_httpx_success + +URL = "http://spoolman:8000" + + +def test_add_text_field(): + """Test adding a text field for spools.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mytextfield", + json={ + "name": "My text field", + "field_type": "text", + "default_value": json.dumps("Hello World"), + }, + ) + assert_httpx_success(result) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield") + assert_httpx_success(result) + + +def test_add_text_field_filament(): + """Test adding a text field for filaments.""" + result = httpx.post( + f"{URL}/api/v1/field/filament/mytextfield", + json={ + "name": "My text field", + "field_type": "text", + "default_value": json.dumps("Hello World"), + }, + ) + assert_httpx_success(result) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/filament/mytextfield") + assert_httpx_success(result) + + +def test_add_text_field_vendor(): + """Test adding a text field for vendors.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/mytextfield", + json={ + "name": "My text field", + "field_type": "text", + "default_value": json.dumps("Hello World"), + }, + ) + assert_httpx_success(result) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/vendor/mytextfield") + assert_httpx_success(result) + + +def test_add_integer_field(): + """Test adding an integer field for spools.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/myintegerfield", + json={ + "name": "My integer field", + "field_type": "integer", + "default_value": json.dumps(42), + }, + ) + assert_httpx_success(result) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/myintegerfield") + assert_httpx_success(result) + + +def test_add_integer_range_field(): + """Test adding an integer range field for spools.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/myintegerrangefield", + json={ + "name": "My integer range field", + "field_type": "integer_range", + "default_value": json.dumps([0, 100]), + }, + ) + assert_httpx_success(result) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/myintegerrangefield") + assert_httpx_success(result) + + +def test_add_float_field(): + """Test adding a float field for spools.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/myfloatfield", + json={ + "name": "My float field", + "field_type": "float", + "default_value": json.dumps(3.14), + }, + ) + assert_httpx_success(result) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/myfloatfield") + assert_httpx_success(result) + + +def test_add_float_range_field(): + """Test adding a float range field for spools.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/myfloatrangefield", + json={ + "name": "My float range field", + "field_type": "float_range", + "default_value": json.dumps([0.0, 1.0]), + }, + ) + assert_httpx_success(result) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/myfloatrangefield") + assert_httpx_success(result) + + +def test_add_datetime_field(): + """Test adding a datetime field for spools.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mydatetimefield", + json={ + "name": "My datetime field", + "field_type": "datetime", + "default_value": json.dumps(datetime.now(timezone.utc).isoformat()), + }, + ) + assert_httpx_success(result) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/mydatetimefield") + assert_httpx_success(result) + + +def test_add_boolean_field(): + """Test adding a boolean field for spools.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mybooleanfield", + json={ + "name": "My boolean field", + "field_type": "boolean", + "default_value": json.dumps(True), # noqa: FBT003 + }, + ) + assert_httpx_success(result) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/mybooleanfield") + assert_httpx_success(result) + + +@pytest.mark.parametrize( + "multi_choice", + [True, False], +) +def test_add_choice_field(multi_choice: bool): # noqa: FBT001 + """Test adding a choice field for spools.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mychoicefield", + json={ + "name": "My choice field", + "field_type": "choice", + "choices": ["foo", "bar", "baz"], + "default_value": json.dumps(["foo"]) if multi_choice else json.dumps("foo"), + "multi_choice": multi_choice, + }, + ) + assert_httpx_success(result) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/mychoicefield") + assert_httpx_success(result) + + +def test_add_text_field_invalid_data(): + """Test adding a text field with invalid default value.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mytextfield", + json={ + "name": "My text field", + "field_type": "text", + "default_value": json.dumps(42), + }, + ) + assert result.status_code == 400 + assert result.json()["message"] == "Default value is not valid: Value is not a string." + + +def test_add_choice_field_without_multi_choice(): + """Test adding a choice field without multi_choice set.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mychoicefield", + json={ + "name": "My choice field", + "field_type": "choice", + "choices": ["foo", "bar", "baz"], + "default_value": json.dumps("foo"), + }, + ) + assert result.status_code == 400 + assert result.json()["message"] == "Multi choice must be set for field type choice." + + +def test_add_choice_field_invalid_choices(): + """Test adding a choice field with invalid choices.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mychoicefield", + json={ + "name": "My choice field", + "field_type": "choice", + "choices": ["foo", "bar", "baz", {"foo": "bar"}], + "default_value": json.dumps(["foo"]), + "multi_choice": True, + }, + ) + assert result.status_code == 422 + + +def test_add_choice_field_invalid_default_value(): + """Test adding a choice field with invalid default value.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mychoicefield", + json={ + "name": "My choice field", + "field_type": "choice", + "choices": ["foo", "bar", "baz"], + "default_value": json.dumps(42), + "multi_choice": False, + }, + ) + assert result.status_code == 400 + assert result.json()["message"] == "Default value is not valid: Value is not a string." + + +def test_add_choice_field_no_choices(): + """Test adding a choice field without choices set.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mychoicefield", + json={ + "name": "My choice field", + "field_type": "choice", + "default_value": json.dumps("foo"), + "multi_choice": True, + }, + ) + assert result.status_code == 400 + assert result.json()["message"] == "Choices must be set for field type choice." diff --git a/tests_integration/tests/fields/test_delete.py b/tests_integration/tests/fields/test_delete.py new file mode 100644 index 0000000..3c540a5 --- /dev/null +++ b/tests_integration/tests/fields/test_delete.py @@ -0,0 +1,31 @@ +"""Integration tests for the custom extra fields system.""" + +import json + +import httpx + +from ..conftest import assert_httpx_success + +URL = "http://spoolman:8000" + + +def test_delete_field(): + """Test adding a field, deleting it, and making sure it's gone.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mytextfield", + json={ + "name": "My text field", + "field_type": "text", + "default_value": json.dumps("Hello World"), + }, + ) + assert_httpx_success(result) + + # Delete + result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield") + assert_httpx_success(result) + + # Verify + result = httpx.get(f"{URL}/api/v1/field/spool") + assert_httpx_success(result) + assert result.json() == [] diff --git a/tests_integration/tests/fields/test_get.py b/tests_integration/tests/fields/test_get.py new file mode 100644 index 0000000..26da655 --- /dev/null +++ b/tests_integration/tests/fields/test_get.py @@ -0,0 +1,60 @@ +"""Integration tests for the custom extra fields system.""" + +import json + +import httpx + +from ..conftest import assert_httpx_success, assert_lists_compatible + +URL = "http://spoolman:8000" + + +def test_get_field(): + """Test adding a couple of fields to the spool and then getting them.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/mytextfield", + json={ + "name": "My text field", + "field_type": "text", + "default_value": json.dumps("Hello World"), + }, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/field/spool/myintfield", + json={ + "name": "My int field", + "field_type": "integer", + "default_value": json.dumps(42), + }, + ) + assert_httpx_success(result) + + result = httpx.get(f"{URL}/api/v1/field/spool") + assert_httpx_success(result) + assert_lists_compatible( + result.json(), + [ + { + "name": "My text field", + "key": "mytextfield", + "field_type": "text", + "default_value": json.dumps("Hello World"), + }, + { + "name": "My int field", + "key": "myintfield", + "field_type": "integer", + "default_value": json.dumps(42), + }, + ], + sort_key="key", + ) + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield") + assert_httpx_success(result) + + result = httpx.delete(f"{URL}/api/v1/field/spool/myintfield") + assert_httpx_success(result)