Skip to content

Commit 12c3b28

Browse files
committed
tests: Improve spatial filter reliability and add tests
- Add new tests for spatial filters (near, distance_lt, bounds) in src/sa_api_v2/tests/test_spatial_views.py. - Fix GEOSException import and handling in to_geom utility to gracefully handle invalid geometry strings. - Improve error handling for bounding box filters in LocatedResourceMixin to return 400 Bad Request instead of 500 when provided with invalid inputs. - Explicitly set SRID 4326 for bounding box polygons to ensure consistency with database queries.
1 parent 317ee4d commit 12c3b28

3 files changed

Lines changed: 137 additions & 2 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from django.test import TestCase
2+
from django.test.client import RequestFactory
3+
from django.urls import reverse
4+
from django.conf import settings
5+
from sa_api_v2.models import User, DataSet, Place
6+
from sa_api_v2.views import PlaceListView
7+
import json
8+
9+
class APITestMixin(object):
10+
def assertStatusCode(self, response, *expected):
11+
self.assertIn(response.status_code, expected,
12+
'Status code not in %s response: (%s) %s' %
13+
(expected, response.status_code, response.rendered_content))
14+
15+
class TestSpatialViews(APITestMixin, TestCase):
16+
def setUp(self):
17+
self.owner = User.objects.create_user(username='aaron', password='123', email='abc@example.com')
18+
self.dataset = DataSet.objects.create(slug='ds', owner=self.owner)
19+
self.factory = RequestFactory()
20+
21+
# Create base path for place list
22+
self.request_kwargs = {
23+
'owner_username': self.owner.username,
24+
'dataset_slug': self.dataset.slug
25+
}
26+
self.path = reverse('place-list', kwargs=self.request_kwargs)
27+
self.view = PlaceListView.as_view()
28+
29+
# Create some places at specific coordinates
30+
# (0,0), (10,0), (20,0), (30,0)
31+
self.p1 = Place.objects.create(dataset=self.dataset, geometry='POINT(0 0)', data=json.dumps({'name': 1}))
32+
self.p2 = Place.objects.create(dataset=self.dataset, geometry='POINT(10 0)', data=json.dumps({'name': 2}))
33+
self.p3 = Place.objects.create(dataset=self.dataset, geometry='POINT(20 0)', data=json.dumps({'name': 3}))
34+
self.p4 = Place.objects.create(dataset=self.dataset, geometry='POINT(30 0)', data=json.dumps({'name': 4}))
35+
36+
def test_GET_near_valid(self):
37+
"""GET with valid 'near' param should sort by distance."""
38+
# Request near (0, 0)
39+
request = self.factory.get(self.path + '?near=0,0')
40+
response = self.view(request, **self.request_kwargs)
41+
42+
self.assertStatusCode(response, 200)
43+
data = json.loads(response.rendered_content)
44+
45+
# Should be ordered 1, 2, 3, 4
46+
self.assertEqual(len(data['features']), 4)
47+
names = [f['properties']['name'] for f in data['features']]
48+
self.assertEqual(names, [1, 2, 3, 4])
49+
50+
# Request near (30, 0). to_geom expects "lat, lng".
51+
# So we pass "0, 30" (Lat 0, Lon 30).
52+
request = self.factory.get(self.path + '?near=0,30')
53+
response = self.view(request, **self.request_kwargs)
54+
55+
self.assertStatusCode(response, 200)
56+
data = json.loads(response.rendered_content)
57+
58+
# Should be ordered 4, 3, 2, 1
59+
names = [f['properties']['name'] for f in data['features']]
60+
self.assertEqual(names, [4, 3, 2, 1])
61+
62+
def test_GET_near_invalid(self):
63+
"""GET with invalid 'near' param should return 400."""
64+
# Invalid format (not enough coords)
65+
request = self.factory.get(self.path + '?near=0')
66+
response = self.view(request, **self.request_kwargs)
67+
self.assertStatusCode(response, 400)
68+
69+
# Invalid format (non-numeric)
70+
request = self.factory.get(self.path + '?near=foo,bar')
71+
response = self.view(request, **self.request_kwargs)
72+
self.assertStatusCode(response, 400)
73+
74+
def test_GET_bbox_valid(self):
75+
"""GET with valid 'bbox' param should filter places."""
76+
# Approx bounding box around (9, -1) to (21, 1) -> Should catch 2 (10,0) and 3 (20,0)
77+
# Bbox format: min_lon,min_lat,max_lon,max_lat
78+
request = self.factory.get(self.path + '?bounds=9,-1,21,1')
79+
response = self.view(request, **self.request_kwargs)
80+
81+
self.assertStatusCode(response, 200)
82+
data = json.loads(response.rendered_content)
83+
84+
self.assertEqual(len(data['features']), 2)
85+
names = sorted([f['properties']['name'] for f in data['features']])
86+
self.assertEqual(names, [2, 3])
87+
88+
def test_GET_bbox_invalid(self):
89+
"""GET with invalid 'bounds' param should return 400."""
90+
# Not enough coords
91+
request = self.factory.get(self.path + '?bounds=0,0,10')
92+
response = self.view(request, **self.request_kwargs)
93+
self.assertStatusCode(response, 400)
94+
95+
# Non-numeric
96+
request = self.factory.get(self.path + '?bounds=a,b,c,d')
97+
response = self.view(request, **self.request_kwargs)
98+
self.assertStatusCode(response, 400)
99+
100+
def test_GET_distance_valid(self):
101+
"""GET with 'distance' param should filter results by distance from 'near'."""
102+
# Near (0,0), distance 1500km.
103+
# Places at 0 degrees (0km) and 10 degrees (~1111km).
104+
# Place at 20 degrees (~2222km) should be excluded.
105+
request = self.factory.get(self.path + '?near=0,0&distance_lt=1500km')
106+
response = self.view(request, **self.request_kwargs)
107+
108+
self.assertStatusCode(response, 200)
109+
data = json.loads(response.rendered_content)
110+
111+
names = sorted([f['properties']['name'] for f in data['features']])
112+
# Should include 1 (0,0) and 2 (10,0)
113+
self.assertEqual(names, [1, 2])
114+
115+
def test_GET_distance_missing_near(self):
116+
"""GET with 'distance' but missing 'near' should return 400."""
117+
request = self.factory.get(self.path + '?distance_lt=10')
118+
response = self.view(request, **self.request_kwargs)
119+
# The view raises QueryError, which usually maps to 400.
120+
self.assertStatusCode(response, 400)
121+
122+
def test_GET_distance_invalid(self):
123+
"""GET with invalid 'distance' param should return 400."""
124+
request = self.factory.get(self.path + '?near=0,0&distance_lt=invalid')
125+
response = self.view(request, **self.request_kwargs)
126+
self.assertStatusCode(response, 400)

‎src/sa_api_v2/utils.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
if settings.USE_GEODB:
55
from django.contrib.gis.geos import GEOSGeometry, Point
66
from django.contrib.gis.measure import D
7+
from django.contrib.gis.geos.error import GEOSException
8+
else:
9+
class GEOSException(Exception): pass
10+
711
from functools import wraps
812

913
try:
@@ -43,7 +47,7 @@ def to_geom(string):
4347
"""
4448
try:
4549
geom = GEOSGeometry(string)
46-
except ValueError:
50+
except (ValueError, GEOSException):
4751
try:
4852
lat, lng = [float(coord.strip()) for coord in string.split(',')]
4953
except ValueError:

‎src/sa_api_v2/views/base_views.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,12 @@ def get_queryset(self):
534534
if len(bounds) != 4:
535535
raise QueryError(detail='Invalid parameter for "%s": %r' % (BBOX_PARAM, self.request.GET[BBOX_PARAM]))
536536

537-
boundingbox = Polygon.from_bbox(bounds)
537+
try:
538+
boundingbox = Polygon.from_bbox(bounds)
539+
boundingbox.srid = 4326
540+
except ValueError:
541+
raise QueryError(detail='Invalid parameter for "%s": %r' % (BBOX_PARAM, self.request.GET[BBOX_PARAM]))
542+
538543
queryset = queryset.filter(geometry__within=boundingbox)
539544

540545
return queryset

0 commit comments

Comments
 (0)