test(api): Add GraphQL nested filter and auto-filter tests

Introduce declarative GraphQL filter test framework with
`GraphQLFilterTest` and `GraphQLQueryTest` dataclasses. Implement
auto-filter discovery from filter class annotations with per-field-kind
test generators for string, numeric, date, range, and array lookups.

Fixes #15569
This commit is contained in:
Martin Hauser 2026-06-04 17:45:06 +02:00
parent d4d931dd4f
commit cdde9e98fa
No known key found for this signature in database
5 changed files with 1165 additions and 24 deletions

View File

@ -72,7 +72,7 @@
"rearporttemplate:api_list_objects": 12,
"region:api_list_objects": 13,
"region:list_objects_with_permission": 20,
"site:api_list_objects": 16,
"site:api_list_objects": 17,
"site:list_objects_with_permission": 22,
"sitegroup:api_list_objects": 13,
"sitegroup:list_objects_with_permission": 20,

View File

@ -20,6 +20,8 @@ from users.models import ObjectPermission, Token, User
from utilities.testing import (
APITestCase,
APIViewTestCases,
GraphQLFilterTest,
GraphQLQueryTest,
create_test_device,
create_test_nat_ip_pair,
disable_logging,
@ -146,6 +148,19 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase):
bulk_update_data = {
'status': 'planned',
}
graphql_filter_tests = (
GraphQLFilterTest(
name='tenant__name__exact',
filters='tenant: {name: {exact: "Tenant 1"}}',
expected=lambda qs: qs.filter(tenant__name='Tenant 1'),
permissions=('tenancy.view_tenant',),
),
)
def assert_nested_locations_active(self, data):
site_data = data.get('site') or {}
location_names = sorted(location['name'] for location in site_data.get('locations', []))
self.assertEqual(location_names, ['Site1 Active A', 'Site1 Active B'])
@classmethod
def setUpTestData(cls):
@ -160,15 +175,32 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase):
SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'),
)
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
# Site 1's tenant activates the dynamic tenant prefetch (+1 in api_list_objects baseline).
sites = (
Site(region=regions[0], group=groups[0], name='Site 1', slug='site-1'),
Site(region=regions[0], group=groups[0], tenant=tenant, name='Site 1', slug='site-1'),
Site(region=regions[0], group=groups[0], name='Site 2', slug='site-2'),
Site(region=regions[0], group=groups[0], name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
nested_site = Site.objects.get(slug='site-1')
cls.nested_site_pk = nested_site.pk
Location.objects.create(
site=nested_site, name='Site1 Active A', slug='site1-active-a',
status=LocationStatusChoices.STATUS_ACTIVE,
)
Location.objects.create(
site=nested_site, name='Site1 Active B', slug='site1-active-b',
status=LocationStatusChoices.STATUS_ACTIVE,
)
Location.objects.create(
site=nested_site, name='Site1 Planned', slug='site1-planned',
status=LocationStatusChoices.STATUS_PLANNED,
)
rir = RIR.objects.create(name='RFC 6996', is_private=True)
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
asns = [
ASN(asn=65000 + i, rir=rir) for i in range(8)
@ -203,6 +235,19 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase):
},
]
cls.graphql_query_tests = (
GraphQLQueryTest(
name='nested_locations_by_status',
query=(
'{ site(id: ' + str(cls.nested_site_pk) + ') { '
'locations(filters: {status: {exact: STATUS_ACTIVE}}) { name } '
'} }'
),
assert_result=cls.assert_nested_locations_active,
permissions=('dcim.view_location',),
),
)
def test_add_tags(self):
"""
Add tags to an existing object via the add_tags field.
@ -427,6 +472,16 @@ class LocationTestCase(APIViewTestCases.APIViewTestCase):
'description': 'New description',
}
user_permissions = ('dcim.view_site',)
graphql_filter_tests = (
GraphQLFilterTest(
name='status__in_list',
filters='status: {in_list: [STATUS_PLANNED, STATUS_STAGING]}',
expected=lambda qs: qs.filter(status__in=[
LocationStatusChoices.STATUS_PLANNED,
LocationStatusChoices.STATUS_STAGING,
]),
),
)
@classmethod
def setUpTestData(cls):
@ -476,6 +531,20 @@ class LocationTestCase(APIViewTestCases.APIViewTestCase):
parent=parent_locations[0],
status=LocationStatusChoices.STATUS_ACTIVE,
)
Location.objects.create(
site=sites[0],
name='GraphQL Planned Location',
slug='graphql-planned-location',
parent=parent_locations[0],
status=LocationStatusChoices.STATUS_PLANNED,
)
Location.objects.create(
site=sites[0],
name='GraphQL Staging Location',
slug='graphql-staging-location',
parent=parent_locations[0],
status=LocationStatusChoices.STATUS_STAGING,
)
cls.create_data = [
{

View File

@ -10,7 +10,7 @@ from strawberry.schema.config import StrawberryConfig
from dcim.choices import LocationStatusChoices
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
from extras.models import TableConfig
from extras.models import TableConfig, Tag
from netbox.graphql.scalars import BigInt, BigIntScalar
from netbox.graphql.schema import Query, get_schema_extensions
from utilities.tables import get_table_for_model
@ -185,6 +185,72 @@ class GraphQLAPITestCase(APITestCase):
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site']['locations']), 0)
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_nested_filter_objects(self):
"""
Test filtering of nested GraphQL object lists.
"""
self.add_permissions('dcim.view_site', 'dcim.view_location', 'extras.view_tag')
site = Site.objects.create(
name='Nested Filter Site',
slug='nested-filter-site'
)
# Location is MPTT-managed; bulk_create skips tree-init hooks. Use per-instance create.
Location.objects.create(
site=site,
name='Nested Active 1',
slug='nested-active-1',
status=LocationStatusChoices.STATUS_ACTIVE,
)
Location.objects.create(
site=site,
name='Nested Active 2',
slug='nested-active-2',
status=LocationStatusChoices.STATUS_ACTIVE,
)
Location.objects.create(
site=site,
name='Nested Planned',
slug='nested-planned',
status=LocationStatusChoices.STATUS_PLANNED,
)
planned = Tag.objects.create(name='Planned', slug='planned')
production = Tag.objects.create(name='Production', slug='production')
staging = Tag.objects.create(name='Staging', slug='staging')
site.tags.add(planned, production, staging)
url = reverse('graphql')
query = f"""
{{
site(id: {site.pk}) {{
locations(filters: {{status: {{exact: STATUS_ACTIVE}}}}) {{
name
}}
tags(filters: {{name: {{i_starts_with: "P"}}}}) {{
name
}}
}}
}}
"""
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(
{location['name'] for location in data['data']['site']['locations']},
{'Nested Active 1', 'Nested Active 2'}
)
self.assertEqual(
{tag['name'] for tag in data['data']['site']['tags']},
{'Planned', 'Production'}
)
def test_graphql_integer_range_lookup(self):
"""
Test that range_lookup works for integer fields (e.g. vc_position). Regression test for #20468.

View File

@ -1,21 +1,50 @@
import copy
import importlib
import inspect
import json
import types
import typing
from collections.abc import Callable
from dataclasses import dataclass
from decimal import Decimal
import strawberry
import strawberry_django
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.test import override_settings
from django.urls import reverse
from django.utils.module_loading import import_string
from rest_framework import status
from rest_framework.test import APIClient
from strawberry.types.base import StrawberryList, StrawberryOptional
from strawberry.types.lazy_type import LazyType
from strawberry.types.union import StrawberryUnion
from strawberry_django import (
BaseFilterLookup,
ComparisonFilterLookup,
DateFilterLookup,
DatetimeFilterLookup,
FilterLookup,
RangeLookup,
StrFilterLookup,
TimeFilterLookup,
)
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType
from ipam.graphql.types import IPAddressFamilyType
from netbox.graphql.filter_lookups import (
ArrayLookup,
BigIntegerLookup,
FloatLookup,
IntegerLookup,
IntegerRangeArrayLookup,
JSONFilter,
TreeNodeFilter,
)
from netbox.models.features import ChangeLoggingMixin
from users.constants import TOKEN_PREFIX
from users.models import ObjectPermission, Token, User
@ -28,9 +57,48 @@ from .utils import disable_logging, disable_warnings, get_random_string
__all__ = (
'APITestCase',
'APIViewTestCases',
'GraphQLFilterTest',
'GraphQLQueryTest',
)
@dataclass(frozen=True)
class GraphQLFilterTest:
"""
Declarative GraphQL filter test case for APIViewTestCases.GraphQLTestCase.
``filters`` is the raw content to place inside the GraphQL ``filters`` input,
e.g. ``name: {i_contains: "site"}``.
``expected`` may be a callable accepting the model queryset, an ORM filter
dict, a queryset, an iterable of model instances, or an iterable of object
IDs. When omitted, the test only asserts that the filter returns at least one
result; this preserves compatibility with the legacy ``graphql_filter``
attribute.
"""
name: str
filters: str
expected: object = None
permissions: tuple[str, ...] = ()
@dataclass(frozen=True)
class GraphQLQueryTest:
"""
Declarative GraphQL query test case for model-specific complex queries.
``assert_result`` is called as ``assert_result(testcase, data)`` where
``testcase`` is the running ``GraphQLTestCase`` instance (use it for
``testcase.assertEqual`` etc.) and ``data`` is the decoded GraphQL
``data`` object (the inner ``response.json()['data']``, not the full HTTP
response).
"""
name: str
query: str
assert_result: Callable
permissions: tuple[str, ...] = ()
#
# REST/GraphQL API Tests
#
@ -556,6 +624,21 @@ class APIViewTestCases:
message=changelog_message)
class GraphQLTestCase(APITestCase):
graphql_auto_filter_tests = True
graphql_auto_filter_exclude = ()
# Cap fields per lookup kind to keep test counts balanced across kinds
# (string fields shouldn't crowd out numeric/date/array fields).
graphql_auto_filter_fields_per_kind = 2
# Fail when auto mode is on and no tests were generated.
graphql_auto_filter_required = True
# Additional explicit-list filter cases as GraphQLFilterTest instances.
graphql_filter_tests = ()
# Additional full-query cases (e.g. nested filters) as GraphQLQueryTest instances.
graphql_query_tests = ()
def _get_graphql_base_name(self):
"""
@ -622,26 +705,627 @@ class APIViewTestCases:
return query
@staticmethod
def _graphql_literal(value):
"""
Render a Python value as a GraphQL literal.
"""
if value is None:
return 'null'
if isinstance(value, bool):
return 'true' if value else 'false'
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, Decimal):
return str(float(value))
if isinstance(value, (list, tuple)):
items = ', '.join(
APIViewTestCases.GraphQLTestCase._graphql_literal(v) for v in value
)
return f'[{items}]'
if isinstance(value, str):
return json.dumps(value)
return json.dumps(str(value))
def _render_graphql_filter_value(self, params):
"""
Render the legacy graphql_filter dict value to a GraphQL filter value.
"""
if isinstance(params, str):
return params
if not isinstance(params, dict):
return self._graphql_literal(params)
lookup = params.get('lookup')
value = params['value']
if lookup:
return f'{{{lookup}: {self._graphql_literal(value)}}}'
return self._graphql_literal(value)
def _build_graphql_filter_string(self, **filters):
if not filters:
return ''
filter_expressions = [
f'{field_name}: {self._render_graphql_filter_value(params)}'
for field_name, params in filters.items()
]
return f'(filters: {{{", ".join(filter_expressions)}}})'
def _build_filtered_query(self, name, **filters):
"""
Create a filtered query: i.e. device_list(filters: {name: {i_contains: "akron"}}){.
"""
# TODO: This should be extended to support AND, OR multi-lookups
if filters:
for field_name, params in filters.items():
lookup = params['lookup']
value = params['value']
if lookup:
query = f'{{{lookup}: "{value}"}}'
filter_string = f'{field_name}: {query}'
else:
filter_string = f'{field_name}: "{value}"'
filter_string = f'(filters: {{{filter_string}}})'
else:
filter_string = ''
filter_string = self._build_graphql_filter_string(**filters)
return self._build_query_with_filter(name, filter_string)
def _build_graphql_id_list_query(self, name, filters):
filter_string = f'(filters: {{{filters}}})' if filters else ''
selection = 'id' if self._graphql_type_exposes_id() else '__typename'
return f"""
{{
{name}{filter_string} {{
{selection}
}}
}}
"""
def _graphql_type_exposes_id(self):
"""
Return True when the model's GraphQL type exposes ``id`` as a
queryable selection. Some NetBox types (e.g. Notification,
Subscription) omit ``id`` from the output type; for those, the
assertion path falls back to length-only comparison.
"""
type_class = get_graphql_type_for_model(self.model)
strawberry_definition = getattr(type_class, '__strawberry_definition__', None)
if strawberry_definition is None:
return False
return any(field.name == 'id' for field in strawberry_definition.fields)
def _get_model_graphql_filter_class(self, model=None):
"""
Return the model's GraphQL filter class, if one follows NetBox's
conventional <app>.graphql.filters.<Model>Filter path. ``None`` if
the filter module (or any of its parent packages) is absent or the
class is not present in the module. Import errors originating
inside an existing filter module are re-raised.
"""
model = model or self.model
module_path = f'{model._meta.app_label}.graphql.filters'
class_name = f'{model.__name__}Filter'
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as exc:
# Treat both "<app>.graphql.filters" absent and any missing
# parent (e.g. "<app>.graphql" or "<app>") as "no conventional
# filter class". Real ImportErrors from inside an existing
# filter module still propagate.
if exc.name == module_path or module_path.startswith(f'{exc.name}.'):
return None
raise
return getattr(module, class_name, None)
def _get_graphql_filter_field_names(self):
"""
Return the names exposed by the model's GraphQL filter input, sourced
only from the conventional <app>.graphql.filters.<Model>Filter path.
"""
filter_class = self._get_model_graphql_filter_class()
if filter_class is None:
return set()
return self._collect_filter_class_annotation_names(filter_class)
@staticmethod
def _collect_filter_class_annotation_names(filter_class):
field_names = set()
for cls in reversed(getattr(filter_class, '__mro__', ())):
field_names.update(
field_name for field_name in getattr(cls, '__annotations__', {})
if not field_name.startswith('_')
)
return field_names
def _assert_graphql_filter_class_present(self, filter_fields, handwritten_tests=()):
"""
Raise when the model has no discoverable filter class or the class
declares no fields. Skipped when auto-filter generation is disabled,
the per-model opt-out attribute is set, or hand-written (legacy or
explicit) filter tests are declared for the model.
"""
if handwritten_tests:
return
if not getattr(self, 'graphql_auto_filter_required', True):
return
if not getattr(self, 'graphql_auto_filter_tests', True):
return
label = self.model._meta.label
path = f'{self.model._meta.app_label}.graphql.filters.{self.model.__name__}Filter'
filter_class = self._get_model_graphql_filter_class()
self.assertIsNotNone(
filter_class,
f'No GraphQL filter class found for {label} at {path}. '
f'Set graphql_auto_filter_required = False on this test case if intentional.'
)
self.assertTrue(
filter_fields,
f'GraphQL filter class for {label} declares no fields. '
f'Set graphql_auto_filter_required = False on this test case if intentional.'
)
def _get_nonempty_field_value(self, field):
queryset = self._get_queryset()
if getattr(field, 'null', False):
queryset = queryset.exclude(**{f'{field.name}__isnull': True})
if isinstance(field, (models.CharField, models.TextField)):
queryset = queryset.exclude(**{field.name: ''})
return queryset.values_list(field.name, flat=True).first()
def _get_model_field_for_filter_field(self, field_name):
"""
Find the Django model field matching a filter field name. Filter
fields are declared with either the model field name (e.g. `name`)
or the FK attname (e.g. `tenant_id`).
"""
for field in self.model._meta.fields:
if field.name == field_name or getattr(field, 'attname', None) == field_name:
return field
return None
def _iter_filter_class_annotations(self, filter_class):
"""
Yield (field_name, annotation) pairs for the filter class, walking
its MRO so inherited fields surface. Subclass annotations override
inherited ones (private `_`-prefixed names are skipped).
"""
annotations = {}
for cls in reversed(filter_class.__mro__):
annotations.update({
name: ann for name, ann in getattr(cls, '__annotations__', {}).items()
if not name.startswith('_')
})
yield from annotations.items()
@staticmethod
def _unwrap_filter_annotation(annotation):
"""
Strip ``X | None`` / ``Optional[X]`` and ``Annotated[X, ...]``
layers. Resolve `strawberry.lazy('...')` metadata so lazily-annotated
lookup types (e.g. ``Annotated['FloatLookup', strawberry.lazy('mod')] | None``)
are returned as the actual class. When an ``Annotated`` layer carries
multiple metadata entries, the first ``module``-bearing entry wins.
Returns None when the inner type cannot be resolved.
"""
if annotation is None:
return None
lazy_module = None
# Cap iterations at 8: typical NetBox annotations nest at most 3 layers
# (Union > Annotated > ForwardRef). 8 is a generous safety net to
# prevent infinite loops on pathological / future annotation shapes.
for _ in range(8):
origin = typing.get_origin(annotation)
args = typing.get_args(annotation)
if origin in (typing.Union, types.UnionType):
non_none = [a for a in args if a is not type(None)]
if len(non_none) != 1:
return None
annotation = non_none[0]
continue
if hasattr(annotation, '__metadata__'):
for meta in annotation.__metadata__:
module_name = getattr(meta, 'module', None)
if module_name:
lazy_module = module_name
break
inner = args[0] if args else None
if inner is None:
return None
annotation = inner
continue
break
if isinstance(annotation, (str, typing.ForwardRef)):
if lazy_module is None:
return None
name = annotation.__forward_arg__ if isinstance(annotation, typing.ForwardRef) else annotation
try:
return import_string(f'{lazy_module}.{name}')
except ImportError:
return None
return annotation
@classmethod
def _classify_filter_annotation(cls, annotation):
"""
Resolve a filter field annotation to a (kind, kind_arg) tuple keyed
on the declared GraphQL lookup type. Returns (None, None) for
annotations the dispatcher does not handle (those fields are
silently skipped).
"""
annotation = cls._unwrap_filter_annotation(annotation)
if annotation is None or isinstance(annotation, str):
return None, None
if annotation is strawberry.ID:
return 'id', None
origin = typing.get_origin(annotation)
target = origin if isinstance(origin, type) else annotation
type_args = typing.get_args(annotation)
if not isinstance(target, type):
return None, None
if target in (IntegerLookup, BigIntegerLookup, FloatLookup):
return 'numeric', target
# TreeNodeFilter schema requires {id, match_type}; skip auto-emit.
if target is TreeNodeFilter:
return None, None
if issubclass(target, (DateFilterLookup, DatetimeFilterLookup, TimeFilterLookup)):
return 'date_lookup', None
if target is RangeLookup or issubclass(target, RangeLookup):
return 'range_lookup', type_args[0] if type_args else None
if issubclass(target, ArrayLookup):
return 'array_lookup', None
if target is IntegerRangeArrayLookup or issubclass(target, IntegerRangeArrayLookup):
return 'range_array_lookup', None
if target is JSONFilter:
# JSONFilter requires explicit (path, typed lookup); no general auto shape.
return None, None
if issubclass(target, StrFilterLookup):
return 'str_lookup', None
if issubclass(target, ComparisonFilterLookup):
return 'comparison_lookup', type_args[0] if type_args else None
if issubclass(target, FilterLookup):
return 'filter_lookup', type_args[0] if type_args else None
# Enum-typed BaseFilterLookup needs an enum literal; skip auto-emit.
if issubclass(target, BaseFilterLookup):
return None, None
return None, None
def _emit_id_filter_tests(self, field_name, _kind_arg):
if field_name == 'id':
instance = self._get_queryset().first()
if instance is None:
return
yield GraphQLFilterTest(
name='id__exact',
filters=f'id: {self._graphql_literal(str(instance.pk))}',
expected=lambda qs, pk=instance.pk: qs.filter(pk=pk),
)
return
model_field = self._get_model_field_for_filter_field(field_name)
if model_field is None or not isinstance(model_field, models.ForeignKey):
return
queryset = self._get_queryset().exclude(**{f'{model_field.name}__isnull': True})
value = queryset.values_list(model_field.attname, flat=True).first()
if value is None:
return
yield GraphQLFilterTest(
name=f'{field_name}__exact',
filters=f'{field_name}: {self._graphql_literal(str(value))}',
expected=lambda qs, attname=model_field.attname, v=value: qs.filter(**{attname: v}),
)
def _emit_str_lookup_filter_tests(self, field_name, _kind_arg):
model_field = self._get_model_field_for_filter_field(field_name)
if model_field is None:
return
value = self._get_nonempty_field_value(model_field)
if value in (None, ''):
return
value = str(value)
token = max(1, min(3, len(value)))
lookups = (
('exact', 'exact', value),
('i_contains', 'icontains', value[:token]),
('i_starts_with', 'istartswith', value[:token]),
('i_ends_with', 'iendswith', value[-token:]),
)
for lookup, orm_lookup, filter_value in lookups:
yield GraphQLFilterTest(
name=f'{field_name}__{lookup}',
filters=f'{field_name}: {{{lookup}: {self._graphql_literal(filter_value)}}}',
expected=(
lambda qs, fn=model_field.name, ol=orm_lookup, v=filter_value:
qs.filter(**{f'{fn}__{ol}': v})
),
)
def _emit_filter_lookup_filter_tests(self, field_name, type_arg):
model_field = self._get_model_field_for_filter_field(field_name)
if model_field is None:
return
value = self._get_nonempty_field_value(model_field)
if value is None:
return
if type_arg is bool or isinstance(value, bool):
yield GraphQLFilterTest(
name=f'{field_name}__exact',
filters=f'{field_name}: {{exact: {self._graphql_literal(value)}}}',
expected=lambda qs, fn=model_field.name, v=value: qs.filter(**{fn: v}),
)
return
yield GraphQLFilterTest(
name=f'{field_name}__exact',
filters=f'{field_name}: {{exact: {self._graphql_literal(value)}}}',
expected=lambda qs, fn=model_field.name, v=value: qs.filter(**{f'{fn}__exact': v}),
)
def _emit_comparison_lookup_filter_tests(self, field_name, _type_arg):
model_field = self._get_model_field_for_filter_field(field_name)
if model_field is None:
return
value = self._get_nonempty_field_value(model_field)
if value is None:
return
yield GraphQLFilterTest(
name=f'{field_name}__exact',
filters=f'{field_name}: {{exact: {self._graphql_literal(value)}}}',
expected=lambda qs, fn=model_field.name, v=value: qs.filter(**{f'{fn}__exact': v}),
)
def _emit_numeric_filter_tests(self, field_name, _type_arg):
# NetBox numeric wrapper: {filter_lookup: {exact: N}}.
model_field = self._get_model_field_for_filter_field(field_name)
if model_field is None:
return
if isinstance(model_field, ArrayField):
return
value = self._get_nonempty_field_value(model_field)
if value is None:
return
if isinstance(value, Decimal):
value = float(value)
yield GraphQLFilterTest(
name=f'{field_name}__filter_lookup__exact',
filters=(
f'{field_name}: {{filter_lookup: '
f'{{exact: {self._graphql_literal(value)}}}}}'
),
expected=lambda qs, fn=model_field.name, v=value: qs.filter(**{f'{fn}__exact': v}),
)
def _emit_date_lookup_filter_tests(self, field_name, _kind_arg):
model_field = self._get_model_field_for_filter_field(field_name)
if model_field is None:
return
value = self._get_nonempty_field_value(model_field)
if value is None:
return
iso_value = value.isoformat() if hasattr(value, 'isoformat') else str(value)
yield GraphQLFilterTest(
name=f'{field_name}__exact',
filters=f'{field_name}: {{exact: "{iso_value}"}}',
expected=lambda qs, fn=model_field.name, v=value: qs.filter(**{fn: v}),
)
def _emit_range_lookup_filter_tests(self, field_name, _kind_arg):
model_field = self._get_model_field_for_filter_field(field_name)
if model_field is None:
return
aggregates = self._get_queryset().aggregate(
_min=models.Min(model_field.name), _max=models.Max(model_field.name),
)
start, end = aggregates['_min'], aggregates['_max']
if start is None or end is None or start == end:
return
yield GraphQLFilterTest(
name=f'{field_name}__range_lookup',
filters=(
f'{field_name}: {{range_lookup: '
f'{{start: {self._graphql_literal(start)}, end: {self._graphql_literal(end)}}}}}'
),
expected=(
lambda qs, fn=model_field.name, lo=start, hi=end:
qs.filter(**{f'{fn}__gte': lo, f'{fn}__lte': hi})
),
)
def _emit_array_lookup_filter_tests(self, field_name, _kind_arg):
model_field = self._get_model_field_for_filter_field(field_name)
if model_field is None:
return
if not isinstance(model_field, ArrayField):
return
queryset = self._get_queryset().exclude(**{field_name: []})
sample = queryset.values_list(field_name, flat=True).first()
if not sample:
return
element = sample[0]
yield GraphQLFilterTest(
name=f'{field_name}__contains',
filters=(
f'{field_name}: {{contains: [{self._graphql_literal(element)}]}}'
),
expected=(
lambda qs, fn=model_field.name, v=element: qs.filter(**{f'{fn}__contains': [v]})
),
)
def _emit_range_array_lookup_filter_tests(self, field_name, _kind_arg):
model_field = self._get_model_field_for_filter_field(field_name)
if model_field is None:
return
queryset = self._get_queryset().exclude(**{f'{field_name}__isnull': True})
sample = queryset.values_list(field_name, flat=True).first()
if not sample:
return
first_range = sample[0]
lower = getattr(first_range, 'lower', None)
if lower is None:
return
yield GraphQLFilterTest(
name=f'{field_name}__contains',
filters=f'{field_name}: {{contains: {self._graphql_literal(lower)}}}',
expected=(
lambda qs, fn=model_field.name, v=lower: qs.filter(**{f'{fn}__range_contains': v})
),
)
def _iter_auto_graphql_filter_tests(self):
if not getattr(self, 'graphql_auto_filter_tests', True):
return
filter_class = self._get_model_graphql_filter_class()
if filter_class is None:
return
exclude = set(getattr(self, 'graphql_auto_filter_exclude', ()))
per_kind = self.graphql_auto_filter_fields_per_kind
# Bucket eligible fields by lookup kind so per-kind budgeting balances coverage.
by_kind: dict[str, list[tuple[str, object]]] = {}
for field_name, annotation in self._iter_filter_class_annotations(filter_class):
if field_name in exclude:
continue
kind, kind_arg = self._classify_filter_annotation(annotation)
if kind is None:
continue
by_kind.setdefault(kind, []).append((field_name, kind_arg))
# Emit per-kind; the cap counts SUCCESSFUL emissions, not candidate fields, so
# early null/empty fields don't shadow later fields with usable fixture data.
for kind, fields in by_kind.items():
emitter = getattr(self, f'_emit_{kind}_filter_tests', None)
if emitter is None:
continue
emitted_fields = 0
for field_name, kind_arg in fields:
tests = list(emitter(field_name, kind_arg))
if not tests:
continue
yield from tests
emitted_fields += 1
if emitted_fields >= per_kind:
break
def _iter_legacy_graphql_filter_tests(self):
if not hasattr(self, 'graphql_filter'):
return
filter_expressions = [
f'{field_name}: {self._render_graphql_filter_value(params)}'
for field_name, params in self.graphql_filter.items()
]
yield GraphQLFilterTest(
name='graphql_filter',
filters=', '.join(filter_expressions),
)
def _coerce_graphql_filter_test(self, filter_test):
if isinstance(filter_test, GraphQLFilterTest):
return filter_test
filter_test = dict(filter_test)
if 'filter' in filter_test and 'filters' not in filter_test:
filter_test['filters'] = filter_test.pop('filter')
return GraphQLFilterTest(**filter_test)
def _iter_explicit_graphql_filter_tests(self):
for filter_test in getattr(self, 'graphql_filter_tests', ()):
yield self._coerce_graphql_filter_test(filter_test)
def _get_expected_id_set(self, filter_test):
expected = filter_test.expected
if callable(expected):
expected = expected(self._get_queryset())
if isinstance(expected, dict):
expected = self._get_queryset().filter(**expected)
if hasattr(expected, 'values_list'):
values = expected.distinct().values_list('pk', flat=True)
else:
values = [getattr(value, 'pk', value) for value in expected]
return {str(value) for value in values}
def _assert_graphql_filter_test(self, url, field_name, filter_test):
query = self._build_graphql_id_list_query(field_name, filter_test.filters)
for permission in filter_test.permissions:
self.add_permissions(permission)
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
results = data['data'][field_name]
if filter_test.expected is None:
self.assertGreater(len(results), 0)
return
expected_ids = self._get_expected_id_set(filter_test)
self.assertGreater(
len(expected_ids), 0,
msg=(
f'{self.model._meta.label}: filter "{filter_test.name}" produced an empty '
f'expected set; the test would tautologically pass. Adjust fixtures or the '
f'filter so the expected ORM queryset is non-empty.'
),
)
if self._graphql_type_exposes_id():
result_ids = [str(result['id']) for result in results]
self.assertEqual(
set(result_ids), expected_ids,
msg=f'{self.model._meta.label}: filter "{filter_test.name}" ID set mismatch',
)
self.assertEqual(
len(results), len(expected_ids),
msg=(
f'{self.model._meta.label}: filter "{filter_test.name}" result count mismatch '
f'(GraphQL type does not expose id; comparing by length).'
),
)
def _coerce_graphql_query_test(self, query_test):
if isinstance(query_test, GraphQLQueryTest):
return query_test
query_test = dict(query_test)
if 'assertion' in query_test and 'assert_result' not in query_test:
query_test['assert_result'] = query_test.pop('assertion')
return GraphQLQueryTest(**query_test)
def _build_query(self, name, **filters):
"""
Create a normal query - unfiltered or with a string query: i.e. site(name: "aaa"){.
@ -740,14 +1424,44 @@ class APIViewTestCases:
self.assertNotIn('errors', data)
self.assertEqual(len(data['data'][field_name]), self.model.objects.count())
def _assert_graphql_filter_tests_exist(self, auto_tests, legacy_tests, explicit_tests):
"""
Fail loudly when auto mode is required and no GraphQL filter tests
(auto, legacy, or explicit) exist for the current model.
"""
if (
getattr(self, 'graphql_auto_filter_tests', True)
and getattr(self, 'graphql_auto_filter_required', True)
and not auto_tests
and not legacy_tests
and not explicit_tests
):
self.fail(
f'No GraphQL filter tests were generated for {self.model._meta.label}. '
f'Set graphql_auto_filter_required = False or add explicit graphql_filter_tests '
f'if intentional.'
)
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_filter_objects(self):
if not hasattr(self, 'graphql_filter'):
legacy_tests = list(self._iter_legacy_graphql_filter_tests())
explicit_tests = list(self._iter_explicit_graphql_filter_tests())
filter_fields = self._get_graphql_filter_field_names()
self._assert_graphql_filter_class_present(
filter_fields, handwritten_tests=[*legacy_tests, *explicit_tests]
)
auto_tests = list(self._iter_auto_graphql_filter_tests())
self._assert_graphql_filter_tests_exist(auto_tests, legacy_tests, explicit_tests)
filter_tests = [*auto_tests, *legacy_tests, *explicit_tests]
if not filter_tests:
return
url = reverse('graphql')
field_name = f'{self._get_graphql_base_name()}_list'
query = self._build_filtered_query(field_name, **self.graphql_filter)
# Add object-level permission
obj_perm = ObjectPermission(
@ -758,11 +1472,43 @@ class APIViewTestCases:
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertGreater(len(data['data'][field_name]), 0)
for filter_test in filter_tests:
with self.subTest(filter=filter_test.name):
self._assert_graphql_filter_test(url, field_name, filter_test)
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_extra_queries(self):
query_tests = [
self._coerce_graphql_query_test(query_test)
for query_test in getattr(self, 'graphql_query_tests', ())
]
if not query_tests:
return
url = reverse('graphql')
# Add object-level permission for this model. Additional permissions
# required by the query can be declared on the GraphQLQueryTest.
obj_perm = ObjectPermission(
name='Test permission',
actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
for query_test in query_tests:
with self.subTest(query=query_test.name):
for permission in query_test.permissions:
self.add_permissions(permission)
response = self.client.post(url, data={'query': query_test.query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
query_test.assert_result(self, data['data'])
class APIViewTestCase(
GetObjectViewTestCase,

View File

@ -0,0 +1,260 @@
"""Tests for the GraphQL filter test framework in utilities/testing/api.py."""
import sys
import types
from decimal import Decimal
from typing import Annotated
import strawberry
from django.test import TestCase
from netbox.graphql.filter_lookups import FloatLookup
from utilities.testing.api import APIViewTestCases, GraphQLFilterTest
class GraphQLFilterAnnotationMROTestCase(TestCase):
"""Cover MRO override, import error propagation, lazy annotation resolution, and the zero-auto-test gate."""
def test_subclass_annotation_overrides_base(self):
"""Subclass annotations win over base in `_iter_filter_class_annotations`."""
class Base:
__annotations__ = {'shared': int}
class Sub(Base):
__annotations__ = {'shared': str}
# Stand up a throwaway instance just to access the method as bound.
instance = APIViewTestCases.GraphQLTestCase()
pairs = dict(instance._iter_filter_class_annotations(Sub))
self.assertEqual(pairs['shared'], str)
def test_get_filter_class_propagates_real_import_errors(self):
"""A broken import inside a model's filters module must surface, not silently return None."""
broken = types.ModuleType('netbox_broken_filter_fixture.graphql.filters')
def _raise(*args, **kwargs):
raise ImportError('simulated downstream breakage')
broken.__getattr__ = _raise
# sys.modules mutation is safe under --parallel (separate processes), not threads.
sys.modules['netbox_broken_filter_fixture'] = types.ModuleType('netbox_broken_filter_fixture')
sys.modules['netbox_broken_filter_fixture.graphql'] = types.ModuleType(
'netbox_broken_filter_fixture.graphql'
)
sys.modules['netbox_broken_filter_fixture.graphql.filters'] = broken
try:
class FakeMeta:
app_label = 'netbox_broken_filter_fixture'
class FakeModel:
_meta = FakeMeta()
__name__ = 'BrokenModel'
instance = APIViewTestCases.GraphQLTestCase()
with self.assertRaises(ImportError):
instance._get_model_graphql_filter_class(FakeModel)
finally:
for key in (
'netbox_broken_filter_fixture',
'netbox_broken_filter_fixture.graphql',
'netbox_broken_filter_fixture.graphql.filters',
):
sys.modules.pop(key, None)
def test_zero_auto_filter_tests_fails_loudly(self):
"""Helper fails when auto mode is required and no tests of any kind exist."""
class FakeMeta:
label = 'fake.FakeModel'
class FakeModel:
_meta = FakeMeta()
class Case(APIViewTestCases.GraphQLTestCase):
model = FakeModel
graphql_auto_filter_tests = True
graphql_auto_filter_required = True
case = Case()
with self.assertRaisesRegex(AssertionError, r'No GraphQL filter tests.*fake\.FakeModel'):
case._assert_graphql_filter_tests_exist([], [], [])
def test_lazy_annotated_lookup_resolves(self):
"""Annotated['FloatLookup', strawberry.lazy(...)] | None resolves to FloatLookup."""
annotation = Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None
self.assertIs(
APIViewTestCases.GraphQLTestCase._unwrap_filter_annotation(annotation),
FloatLookup,
)
def test_str_lookup_emits_all_four_variants(self):
"""`_emit_str_lookup_filter_tests` emits exact, i_contains, i_starts_with, i_ends_with."""
captured_value = 'production'
class FakeMeta:
label = 'fake.FakeModel'
app_label = 'fake'
class Case(APIViewTestCases.GraphQLTestCase):
def _get_model_field_for_filter_field(self, field_name):
class FakeField:
name = field_name
return FakeField()
def _get_nonempty_field_value(self, field):
return captured_value
def _graphql_literal(self, value):
return f'"{value}"'
case = Case()
tests = list(case._emit_str_lookup_filter_tests('name', None))
self.assertEqual(
[t.name for t in tests],
['name__exact', 'name__i_contains', 'name__i_starts_with', 'name__i_ends_with'],
)
def test_explicit_tests_satisfy_auto_required_gate(self):
"""Helper does NOT fail when explicit tests exist, even if auto/legacy are empty."""
class FakeMeta:
label = 'fake.FakeModel'
class FakeModel:
_meta = FakeMeta()
class Case(APIViewTestCases.GraphQLTestCase):
model = FakeModel
graphql_auto_filter_tests = True
graphql_auto_filter_required = True
case = Case()
# Should not raise.
case._assert_graphql_filter_tests_exist(
auto_tests=[],
legacy_tests=[],
explicit_tests=[GraphQLFilterTest(name='x', filters='x: 1')],
)
def test_graphql_literal_renders_lists(self):
"""List and tuple values render as GraphQL list literals, not quoted strings."""
literal = APIViewTestCases.GraphQLTestCase._graphql_literal
self.assertEqual(literal([1, 2, 3]), '[1, 2, 3]')
self.assertEqual(literal(('a', 'b')), '["a", "b"]')
self.assertEqual(literal([]), '[]')
def test_graphql_literal_renders_decimal_as_number(self):
"""Decimal values render as numeric literals, not quoted strings."""
literal = APIViewTestCases.GraphQLTestCase._graphql_literal
self.assertEqual(literal(Decimal('1.23')), '1.23')
self.assertEqual(literal([Decimal('1.5'), Decimal('2.5')]), '[1.5, 2.5]')
def test_per_kind_cap_counts_successful_emissions(self):
"""Later candidate fields are tried until per-kind successful emissions reach the cap."""
class FakeMeta:
label = 'fake.FakeModel'
app_label = 'fake'
class FakeModel:
_meta = FakeMeta()
__name__ = 'FakeModel'
emit_calls = []
class Case(APIViewTestCases.GraphQLTestCase):
model = FakeModel
graphql_auto_filter_fields_per_kind = 2
def _get_model_graphql_filter_class(self, model=None):
class FilterClass:
__annotations__ = {
'empty_field_1': str,
'empty_field_2': str,
'useful_field_1': str,
'useful_field_2': str,
'useful_field_3': str,
}
return FilterClass
def _classify_filter_annotation(self, annotation):
return 'str_lookup', None
def _emit_str_lookup_filter_tests(self, field_name, _kind_arg):
emit_calls.append(field_name)
if field_name.startswith('empty_'):
return iter(())
return iter((GraphQLFilterTest(name=field_name, filters=f'{field_name}: "x"'),))
case = Case()
list(case._iter_auto_graphql_filter_tests())
# The candidate-counting bug stops at 'empty_field_1' and 'empty_field_2' (the slice
# captures the first 2). After the fix, the emitter is invoked on all 5 candidates
# in order until 2 SUCCESSFUL fields have emitted.
self.assertEqual(
emit_calls,
['empty_field_1', 'empty_field_2', 'useful_field_1', 'useful_field_2'],
)
def test_get_filter_class_returns_none_when_parent_module_missing(self):
"""When the parent `<app>.graphql` package is absent, return None instead of re-raising."""
class FakeMeta:
app_label = 'netbox_missing_graphql_fixture'
class FakeModel:
_meta = FakeMeta()
__name__ = 'BrokenModel'
instance = APIViewTestCases.GraphQLTestCase()
# No `netbox_missing_graphql_fixture` package is registered in sys.modules,
# so import_module raises ModuleNotFoundError with exc.name == 'netbox_missing_graphql_fixture'
# (the parent), not the full path 'netbox_missing_graphql_fixture.graphql.filters'.
# The fix accepts both shapes.
self.assertIsNone(instance._get_model_graphql_filter_class(FakeModel))
def test_filter_class_assertion_skipped_with_handwritten_tests(self):
"""Hand-written tests exempt a model from the conventional filter class requirement."""
class FakeMeta:
label = 'fake.FakeModel'
app_label = 'fake'
class FakeModel:
_meta = FakeMeta()
__name__ = 'FakeModel'
class Case(APIViewTestCases.GraphQLTestCase):
model = FakeModel
def _get_model_graphql_filter_class(self, model=None):
return None
case = Case()
# Should not raise despite the missing conventional filter class.
case._assert_graphql_filter_class_present(
set(), handwritten_tests=[GraphQLFilterTest(name='x', filters='x: 1')]
)
def test_filter_class_assertion_fails_without_filter_class(self):
"""Missing conventional filter class raises when no hand-written tests exist."""
class FakeMeta:
label = 'fake.FakeModel'
app_label = 'fake'
class FakeModel:
_meta = FakeMeta()
__name__ = 'FakeModel'
class Case(APIViewTestCases.GraphQLTestCase):
model = FakeModel
def _get_model_graphql_filter_class(self, model=None):
return None
case = Case()
with self.assertRaisesRegex(AssertionError, r'No GraphQL filter class found for fake\.FakeModel'):
case._assert_graphql_filter_class_present(set())