@@ -832,3 +832,266 @@ class Query(ObjectType):
832832 assert "type Reporter implements Node {" not in schema
833833 assert "type ReporterConnection {" not in schema
834834 assert "type ReporterEdge {" not in schema
835+
836+
837+ class TestValidateFieldsHelpers :
838+ """Direct unit tests for the ``validate_fields`` extraction in
839+ :mod:`graphene_django.types`.
840+
841+ These tests exercise :func:`_validate_only_fields`,
842+ :func:`_validate_exclude_fields`, and the
843+ :func:`validate_fields` orchestrator in isolation — without going
844+ through ``DjangoObjectType.__init_subclass_with_meta__`` — so each
845+ warning case fails a focused test rather than only being detected
846+ via integration coverage on type construction.
847+
848+ Scenarios covered:
849+
850+ * ``_validate_only_fields``
851+ - happy path: requested name is in ``all_field_names`` (no warning)
852+ - requested name resolves to a non-field model attribute (warning
853+ about "matches an attribute")
854+ - requested name is unknown to the model (warning about "doesn't
855+ exist on Django model")
856+ - ``only_fields=None`` is a no-op
857+ - ``only_fields=ALL_FIELDS`` is normalised to a no-op by the
858+ orchestrator and is therefore exercised through ``validate_fields``
859+ * ``_validate_exclude_fields``
860+ - happy path: excluded name corresponds to a real model field
861+ (no warning)
862+ - excluded name is a custom field on the type (warning about
863+ "Excluding the custom field")
864+ - excluded name is unknown to the model (warning about "does not
865+ have a field or attribute")
866+ - ``exclude_fields=None`` is a no-op
867+ * ``validate_fields`` orchestrator
868+ - ``ALL_FIELDS`` sentinel for ``only_fields`` is normalised to
869+ empty iteration (and therefore emits no warning)
870+ - delegates to both helpers (combined-warning happy path)
871+
872+ The tests assert on the message text via ``pytest.warns(..., match=...)``
873+ so a regression in the error wording also fails a focused test.
874+ """
875+
876+ @staticmethod
877+ def _all_field_names ():
878+ """Reusable set of "real" converted-field names for ReporterModel."""
879+ return {"first_name" , "last_name" , "email" , "pets" , "custom_field" }
880+
881+ def test_only_fields_known_field_emits_no_warning (self ):
882+ """
883+ Name: only_fields, known field, no warning
884+ Description: A name present in ``all_field_names`` short-circuits via
885+ ``continue`` and emits no warning.
886+ Assumptions: ``first_name`` is one of the converted fields supplied
887+ in ``all_field_names``.
888+ Expectations: ``warnings.simplefilter("error")`` does not raise — i.e.
889+ no warning is emitted.
890+ """
891+ from ..types import _validate_only_fields
892+
893+ with warnings .catch_warnings ():
894+ warnings .simplefilter ("error" )
895+ _validate_only_fields (
896+ ["first_name" ], self ._all_field_names (), ReporterModel , "TestType"
897+ )
898+
899+ def test_only_fields_model_attribute_warns_about_attribute (self ):
900+ """
901+ Name: only_fields, attribute-not-field, attribute warning
902+ Description: A name that exists as a Python attribute on the model but
903+ is not a model field (e.g. ``Reporter.some_method``) must produce
904+ the "matches an attribute" warning so the user knows to declare
905+ the field on the type.
906+ Assumptions: ``Reporter.some_method`` is defined on the model and is
907+ not in ``all_field_names``.
908+ Expectations: A ``UserWarning`` whose message matches the
909+ "matches an attribute on Django model" wording is emitted.
910+ """
911+ from ..types import _validate_only_fields
912+
913+ with pytest .warns (
914+ UserWarning ,
915+ match = r"matches an attribute on Django model .* but it's not a model field" ,
916+ ):
917+ _validate_only_fields (
918+ ["some_method" ], self ._all_field_names (), ReporterModel , "TestType"
919+ )
920+
921+ def test_only_fields_unknown_name_warns_about_missing_field (self ):
922+ """
923+ Name: only_fields, unknown name, missing-field warning
924+ Description: A name that is neither a converted field nor any
925+ attribute on the model must produce the "doesn't exist on
926+ Django model" warning so the user knows it is a typo or stale
927+ entry in ``Meta.fields``.
928+ Assumptions: ``"foo"`` is not a field of ReporterModel and is not
929+ in ``all_field_names``.
930+ Expectations: A ``UserWarning`` whose message matches the
931+ "doesn't exist on Django model" wording is emitted.
932+ """
933+ from ..types import _validate_only_fields
934+
935+ with pytest .warns (
936+ UserWarning ,
937+ match = r"Field name \"foo\" doesn't exist on Django model" ,
938+ ):
939+ _validate_only_fields (
940+ ["foo" ], self ._all_field_names (), ReporterModel , "TestType"
941+ )
942+
943+ def test_only_fields_none_is_a_noop (self ):
944+ """
945+ Name: only_fields, None argument, no-op
946+ Description: Passing ``None`` for ``only_fields`` must iterate over
947+ an empty sequence and emit no warning.
948+ Assumptions: The helper uses ``only_fields or ()`` so ``None`` is
949+ normalised at call site.
950+ Expectations: ``warnings.simplefilter("error")`` does not raise.
951+ """
952+ from ..types import _validate_only_fields
953+
954+ with warnings .catch_warnings ():
955+ warnings .simplefilter ("error" )
956+ _validate_only_fields (
957+ None , self ._all_field_names (), ReporterModel , "TestType"
958+ )
959+
960+ def test_exclude_fields_real_model_field_emits_no_warning (self ):
961+ """
962+ Name: exclude_fields, real model field, no warning
963+ Description: Excluding a name that exists on the model and is not
964+ also a custom field on the type is the happy path and produces
965+ no warning.
966+ Assumptions: ``"first_name"`` is on ReporterModel and is not in the
967+ ``all_field_names`` set passed in (we deliberately use a set
968+ without it to model "this is a real field, not a custom one").
969+ Expectations: ``warnings.simplefilter("error")`` does not raise.
970+ """
971+ from ..types import _validate_exclude_fields
972+
973+ with warnings .catch_warnings ():
974+ warnings .simplefilter ("error" )
975+ _validate_exclude_fields (
976+ ["first_name" ],
977+ {"custom_field" },
978+ ReporterModel ,
979+ "TestType" ,
980+ )
981+
982+ def test_exclude_fields_custom_field_warns_about_no_effect (self ):
983+ """
984+ Name: exclude_fields, custom field, "no effect" warning
985+ Description: Excluding a name that maps to a custom field declared
986+ directly on the ``DjangoObjectType`` has no effect (since
987+ ``Meta.exclude`` only filters auto-converted model fields), so
988+ a warning must be emitted.
989+ Assumptions: ``"custom_field"`` is in the supplied ``all_field_names``
990+ (i.e. it was added by the user as a custom field on the type).
991+ Expectations: A ``UserWarning`` whose message matches the
992+ "Excluding the custom field" wording is emitted.
993+ """
994+ from ..types import _validate_exclude_fields
995+
996+ with pytest .warns (
997+ UserWarning ,
998+ match = r"Excluding the custom field \"custom_field\" on DjangoObjectType" ,
999+ ):
1000+ _validate_exclude_fields (
1001+ ["custom_field" ],
1002+ self ._all_field_names (),
1003+ ReporterModel ,
1004+ "TestType" ,
1005+ )
1006+
1007+ def test_exclude_fields_unknown_name_warns_about_missing_attribute (self ):
1008+ """
1009+ Name: exclude_fields, unknown name, missing-attribute warning
1010+ Description: Excluding a name that is neither a custom field nor any
1011+ attribute of the model must produce the "does not have a field
1012+ or attribute" warning to flag the typo or stale entry.
1013+ Assumptions: ``"foo"`` does not exist on ReporterModel and is not in
1014+ ``all_field_names``.
1015+ Expectations: A ``UserWarning`` matching the "does not have a field
1016+ or attribute" wording is emitted.
1017+ """
1018+ from ..types import _validate_exclude_fields
1019+
1020+ with pytest .warns (
1021+ UserWarning ,
1022+ match = r"does not have a field or attribute named \"foo\"" ,
1023+ ):
1024+ _validate_exclude_fields (
1025+ ["foo" ], self ._all_field_names (), ReporterModel , "TestType"
1026+ )
1027+
1028+ def test_exclude_fields_none_is_a_noop (self ):
1029+ """
1030+ Name: exclude_fields, None argument, no-op
1031+ Description: Passing ``None`` for ``exclude_fields`` must iterate
1032+ over an empty sequence and emit no warning.
1033+ Assumptions: The helper uses ``exclude_fields or ()`` so ``None`` is
1034+ normalised at call site.
1035+ Expectations: ``warnings.simplefilter("error")`` does not raise.
1036+ """
1037+ from ..types import _validate_exclude_fields
1038+
1039+ with warnings .catch_warnings ():
1040+ warnings .simplefilter ("error" )
1041+ _validate_exclude_fields (
1042+ None , self ._all_field_names (), ReporterModel , "TestType"
1043+ )
1044+
1045+ def test_validate_fields_normalises_all_fields_sentinel (self ):
1046+ """
1047+ Name: validate_fields, ALL_FIELDS sentinel
1048+ Description: The orchestrator must treat ``only_fields=ALL_FIELDS``
1049+ as an empty iteration so that the request "all fields" never
1050+ triggers a per-name warning loop.
1051+ Assumptions: :data:`graphene_django.types.ALL_FIELDS` is the sentinel
1052+ used to mean "all fields".
1053+ Expectations: No warning is emitted even though the
1054+ ``ALL_FIELDS`` string itself is not in ``all_field_names``.
1055+ """
1056+ from ..types import ALL_FIELDS , validate_fields
1057+
1058+ fake_fields = {"first_name" : object (), "last_name" : object ()}
1059+ with warnings .catch_warnings ():
1060+ warnings .simplefilter ("error" )
1061+ validate_fields (
1062+ "TestType" ,
1063+ ReporterModel ,
1064+ fake_fields ,
1065+ only_fields = ALL_FIELDS ,
1066+ exclude_fields = None ,
1067+ )
1068+
1069+ def test_validate_fields_delegates_to_both_helpers (self ):
1070+ """
1071+ Name: validate_fields, delegates to both helpers
1072+ Description: The orchestrator must forward both ``only_fields`` and
1073+ ``exclude_fields`` to their respective helpers, so an invalid
1074+ entry in either list still surfaces a warning.
1075+ Assumptions: ``"foo"`` is unknown to the model in both contexts.
1076+ Expectations: Two warnings are emitted in a single call: one for
1077+ the missing-from-fields name and one for the
1078+ missing-from-exclude name.
1079+ """
1080+ from ..types import validate_fields
1081+
1082+ fake_fields = {"first_name" : object ()}
1083+ with warnings .catch_warnings (record = True ) as recorded :
1084+ warnings .simplefilter ("always" )
1085+ validate_fields (
1086+ "TestType" ,
1087+ ReporterModel ,
1088+ fake_fields ,
1089+ only_fields = ["foo" ],
1090+ exclude_fields = ["bar" ],
1091+ )
1092+
1093+ messages = [str (w .message ) for w in recorded ]
1094+ assert any ("Field name \" foo\" doesn't exist" in m for m in messages ), messages
1095+ assert any (
1096+ "does not have a field or attribute named \" bar\" " in m for m in messages
1097+ ), messages
0 commit comments