|
| 1 | +"""Pure unit tests for ``etebase_server.fastapi.exceptions``. |
| 2 | +
|
| 3 | +They cover the HTTP exception hierarchy and the error-transformation helpers. These are |
| 4 | +"classic" unit tests: they need no database, no configured Django settings, and no running |
| 5 | +server. The only requirement is that ``django.core.exceptions``, ``fastapi`` and ``pydantic`` |
| 6 | +are importable. |
| 7 | +""" |
| 8 | + |
| 9 | +import pytest |
| 10 | +from django.core.exceptions import ValidationError as DjangoValidationError |
| 11 | +from fastapi import HTTPException, status |
| 12 | + |
| 13 | +from etebase_server.fastapi.exceptions import ( |
| 14 | + AuthenticationFailed, |
| 15 | + CustomHttpException, |
| 16 | + HttpError, |
| 17 | + NotAuthenticated, |
| 18 | + NotSupported, |
| 19 | + PermissionDenied, |
| 20 | + ValidationError, |
| 21 | + flatten_errors, |
| 22 | + transform_validation_error, |
| 23 | +) |
| 24 | + |
| 25 | + |
| 26 | +class TestCustomHttpException: |
| 27 | + def test_defaults(self): |
| 28 | + exc = CustomHttpException(code="boom", detail="Something broke") |
| 29 | + assert exc.code == "boom" |
| 30 | + assert exc.detail == "Something broke" |
| 31 | + assert exc.status_code == status.HTTP_400_BAD_REQUEST |
| 32 | + assert isinstance(exc, HTTPException) |
| 33 | + |
| 34 | + def test_custom_status_code(self): |
| 35 | + exc = CustomHttpException(code="teapot", detail="no coffee", status_code=status.HTTP_418_IM_A_TEAPOT) |
| 36 | + assert exc.status_code == status.HTTP_418_IM_A_TEAPOT |
| 37 | + |
| 38 | + def test_as_dict(self): |
| 39 | + exc = CustomHttpException(code="boom", detail="broke") |
| 40 | + assert exc.as_dict == {"code": "boom", "detail": "broke"} |
| 41 | + |
| 42 | + |
| 43 | +@pytest.mark.parametrize( |
| 44 | + "exc_cls, expected_code, expected_status", |
| 45 | + [ |
| 46 | + (AuthenticationFailed, "authentication_failed", status.HTTP_401_UNAUTHORIZED), |
| 47 | + (NotAuthenticated, "not_authenticated", status.HTTP_401_UNAUTHORIZED), |
| 48 | + (PermissionDenied, "permission_denied", status.HTTP_403_FORBIDDEN), |
| 49 | + (NotSupported, "not_implemented", status.HTTP_501_NOT_IMPLEMENTED), |
| 50 | + ], |
| 51 | +) |
| 52 | +def test_subclass_defaults(exc_cls, expected_code, expected_status): |
| 53 | + exc = exc_cls() |
| 54 | + assert exc.code == expected_code |
| 55 | + assert exc.status_code == expected_status |
| 56 | + assert isinstance(exc, CustomHttpException) |
| 57 | + |
| 58 | + |
| 59 | +class TestHttpError: |
| 60 | + def test_defaults(self): |
| 61 | + exc = HttpError(code="bad", detail="nope") |
| 62 | + assert exc.code == "bad" |
| 63 | + assert exc.status_code == status.HTTP_400_BAD_REQUEST |
| 64 | + assert exc.errors is None |
| 65 | + |
| 66 | + def test_empty_code_falls_back_to_generic(self): |
| 67 | + exc = HttpError(code="", detail="nope") |
| 68 | + assert exc.code == "generic_error" |
| 69 | + |
| 70 | + def test_as_dict_without_errors(self): |
| 71 | + exc = HttpError(code="bad", detail="nope") |
| 72 | + assert exc.as_dict == {"code": "bad", "detail": "nope", "errors": None} |
| 73 | + |
| 74 | + def test_as_dict_serializes_nested_validation_errors(self): |
| 75 | + nested = ValidationError(code="invalid", detail="too short", field="password") |
| 76 | + exc = HttpError(code="field_errors", detail="Field validations failed.", errors=[nested]) |
| 77 | + result = exc.as_dict |
| 78 | + assert result["code"] == "field_errors" |
| 79 | + assert result["detail"] == "Field validations failed." |
| 80 | + assert result["errors"] == [{"field": "password", "code": "invalid", "detail": "too short"}] |
| 81 | + |
| 82 | + |
| 83 | +class TestValidationError: |
| 84 | + def test_field_is_stored(self): |
| 85 | + exc = ValidationError(code="invalid", detail="bad value", field="email") |
| 86 | + assert exc.field == "email" |
| 87 | + assert exc.code == "invalid" |
| 88 | + assert isinstance(exc, HttpError) |
| 89 | + |
| 90 | + |
| 91 | +class TestTransformValidationError: |
| 92 | + def test_dict_errors_become_field_errors(self): |
| 93 | + django_err = DjangoValidationError({"name": ["This field is required."]}) |
| 94 | + with pytest.raises(HttpError) as exc_info: |
| 95 | + transform_validation_error("user", django_err) |
| 96 | + |
| 97 | + exc = exc_info.value |
| 98 | + assert exc.code == "field_errors" |
| 99 | + assert exc.detail == "Field validations failed." |
| 100 | + assert len(exc.errors) == 1 |
| 101 | + only = exc.errors[0] |
| 102 | + assert isinstance(only, ValidationError) |
| 103 | + assert only.field == "user.name" |
| 104 | + assert only.detail == "This field is required." |
| 105 | + |
| 106 | + def test_list_errors_become_field_errors(self): |
| 107 | + django_err = DjangoValidationError(["first problem", "second problem"]) |
| 108 | + with pytest.raises(HttpError) as exc_info: |
| 109 | + transform_validation_error("user", django_err) |
| 110 | + |
| 111 | + exc = exc_info.value |
| 112 | + assert exc.code == "field_errors" |
| 113 | + assert len(exc.errors) == 2 |
| 114 | + |
| 115 | + def test_single_message_raises_plain_http_error(self): |
| 116 | + django_err = DjangoValidationError("just one message", code="custom_code") |
| 117 | + with pytest.raises(HttpError) as exc_info: |
| 118 | + transform_validation_error("user", django_err) |
| 119 | + |
| 120 | + exc = exc_info.value |
| 121 | + assert exc.code == "custom_code" |
| 122 | + assert exc.detail == "just one message" |
| 123 | + |
| 124 | + |
| 125 | +class TestFlattenErrors: |
| 126 | + def test_flattens_list_of_django_errors(self): |
| 127 | + django_err = DjangoValidationError(["problem a", "problem b"]) |
| 128 | + result = flatten_errors("field", django_err.error_list) |
| 129 | + assert len(result) == 2 |
| 130 | + assert all(isinstance(e, ValidationError) for e in result) |
| 131 | + assert all(e.field == "field" for e in result) |
| 132 | + assert {e.detail for e in result} == {"problem a", "problem b"} |
| 133 | + |
| 134 | + def test_flattens_nested_dict_of_django_errors(self): |
| 135 | + django_err = DjangoValidationError({"name": ["required"], "email": ["invalid"]}) |
| 136 | + result = flatten_errors("user", django_err.error_dict) |
| 137 | + assert len(result) == 2 |
| 138 | + fields = {e.field for e in result} |
| 139 | + assert fields == {"user.name", "user.email"} |
0 commit comments