Skip to content

Commit a0b6ab8

Browse files
ShawK91claude
andcommitted
test(backend): add endpoint tests for feedback, stars, and notifications apps
Adds API test coverage for three previously-untested apps (#229): - feedback: auth required, create sets request user, GeoJSON response shape, invalid geometry/action rejected, stac_id/action filters, owner-only update/delete (IsOwnerOrAdminOrReadOnly) - stars: target_id validation, anonymous star dedupe via IP+UA hash, distinct anon clients counted separately, authed star/unstar state, anon unstar removes only own star - notifications: public banner list hides unstarted banners, expired banners flagged not displayable, per-user notification scoping, mark-read / mark-all-read behavior incl. cross-user isolation Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 25c3fdf commit a0b6ab8

3 files changed

Lines changed: 385 additions & 0 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import pytest
2+
from django.contrib.gis.geos import Polygon
3+
from rest_framework.test import APIClient
4+
5+
from accounts.models import OsmUser
6+
from feedback.models import Feedback
7+
8+
FEEDBACK_URL = "/api/v1/feedback/"
9+
10+
_POLYGON_GEOJSON = {
11+
"type": "Polygon",
12+
"coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]],
13+
}
14+
15+
16+
@pytest.fixture
17+
def authed_user(db) -> OsmUser:
18+
return OsmUser.objects.create(osm_id=42, username="alice")
19+
20+
21+
@pytest.fixture
22+
def client(authed_user: OsmUser) -> APIClient:
23+
api = APIClient()
24+
api.force_authenticate(user=authed_user)
25+
return api
26+
27+
28+
@pytest.fixture
29+
def other_user(db) -> OsmUser:
30+
return OsmUser.objects.create(osm_id=43, username="bob")
31+
32+
33+
@pytest.fixture
34+
def other_client(other_user: OsmUser) -> APIClient:
35+
api = APIClient()
36+
api.force_authenticate(user=other_user)
37+
return api
38+
39+
40+
def _make_feedback(user: OsmUser, **overrides) -> Feedback:
41+
defaults = {
42+
"stac_id": "stac-1",
43+
"geom": Polygon(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0)), srid=4326),
44+
"action": Feedback.Action.ACCEPT,
45+
"user": user,
46+
}
47+
defaults.update(overrides)
48+
return Feedback.objects.create(**defaults)
49+
50+
51+
def _features(payload: dict) -> list:
52+
"""Unwrap a paginated GeoJSON FeatureCollection list response."""
53+
results = payload.get("results", payload)
54+
if isinstance(results, dict) and "features" in results:
55+
return results["features"]
56+
return results
57+
58+
59+
def test_feedback_requires_authentication(db):
60+
response = APIClient().get(FEEDBACK_URL)
61+
assert response.status_code == 401
62+
63+
64+
def test_feedback_create_sets_request_user(client, authed_user):
65+
response = client.post(
66+
FEEDBACK_URL,
67+
data={
68+
"type": "Feature",
69+
"geometry": _POLYGON_GEOJSON,
70+
"properties": {
71+
"stac_id": "stac-1",
72+
"action": "accept",
73+
"comments": "looks right",
74+
},
75+
},
76+
format="json",
77+
)
78+
79+
assert response.status_code == 201
80+
feature = response.json()
81+
assert feature["properties"]["stac_id"] == "stac-1"
82+
assert feature["properties"]["action"] == "accept"
83+
assert feature["properties"]["user"]["osm_id"] == authed_user.osm_id
84+
assert feature["geometry"]["type"] == "Polygon"
85+
assert Feedback.objects.count() == 1
86+
assert Feedback.objects.get().user == authed_user
87+
88+
89+
def test_feedback_create_rejects_invalid_geometry(client):
90+
response = client.post(
91+
FEEDBACK_URL,
92+
data={
93+
"type": "Feature",
94+
"geometry": {"type": "Polygon", "coordinates": "not-coordinates"},
95+
"properties": {"stac_id": "stac-1", "action": "accept"},
96+
},
97+
format="json",
98+
)
99+
100+
assert response.status_code == 400
101+
102+
103+
def test_feedback_create_rejects_unknown_action(client):
104+
response = client.post(
105+
FEEDBACK_URL,
106+
data={
107+
"type": "Feature",
108+
"geometry": _POLYGON_GEOJSON,
109+
"properties": {"stac_id": "stac-1", "action": "maybe"},
110+
},
111+
format="json",
112+
)
113+
114+
assert response.status_code == 400
115+
116+
117+
def test_feedback_list_filters_by_stac_id_and_action(client, authed_user):
118+
_make_feedback(authed_user, stac_id="stac-1")
119+
_make_feedback(authed_user, stac_id="stac-2", action=Feedback.Action.REJECT)
120+
121+
response = client.get(FEEDBACK_URL, {"stac_id": "stac-1"})
122+
assert response.status_code == 200
123+
features = _features(response.json())
124+
assert len(features) == 1
125+
assert features[0]["properties"]["stac_id"] == "stac-1"
126+
127+
response = client.get(FEEDBACK_URL, {"action": "reject"})
128+
assert response.status_code == 200
129+
features = _features(response.json())
130+
assert len(features) == 1
131+
assert features[0]["properties"]["stac_id"] == "stac-2"
132+
133+
134+
def test_feedback_readable_by_other_users(other_client, authed_user):
135+
feedback = _make_feedback(authed_user)
136+
137+
response = other_client.get(f"{FEEDBACK_URL}{feedback.id}/")
138+
assert response.status_code == 200
139+
assert response.json()["properties"]["user"]["osm_id"] == authed_user.osm_id
140+
141+
142+
def test_feedback_update_denied_for_non_owner(other_client, authed_user):
143+
feedback = _make_feedback(authed_user)
144+
145+
response = other_client.patch(
146+
f"{FEEDBACK_URL}{feedback.id}/",
147+
data={"properties": {"comments": "hijacked"}},
148+
format="json",
149+
)
150+
151+
assert response.status_code == 403
152+
feedback.refresh_from_db()
153+
assert feedback.comments == ""
154+
155+
156+
def test_feedback_owner_can_update_and_delete(client, authed_user):
157+
feedback = _make_feedback(authed_user)
158+
159+
response = client.patch(
160+
f"{FEEDBACK_URL}{feedback.id}/",
161+
data={"properties": {"comments": "updated"}},
162+
format="json",
163+
)
164+
assert response.status_code == 200
165+
feedback.refresh_from_db()
166+
assert feedback.comments == "updated"
167+
168+
response = client.delete(f"{FEEDBACK_URL}{feedback.id}/")
169+
assert response.status_code == 204
170+
assert Feedback.objects.count() == 0
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from datetime import timedelta
2+
3+
import pytest
4+
from django.utils import timezone
5+
from rest_framework.test import APIClient
6+
7+
from accounts.models import OsmUser
8+
from notifications.models import Banner, UserNotification
9+
10+
BANNERS_URL = "/api/v1/banners/"
11+
NOTIFICATIONS_URL = "/api/v1/notifications/me/"
12+
13+
14+
@pytest.fixture
15+
def authed_user(db) -> OsmUser:
16+
return OsmUser.objects.create(osm_id=42, username="alice")
17+
18+
19+
@pytest.fixture
20+
def client(authed_user: OsmUser) -> APIClient:
21+
api = APIClient()
22+
api.force_authenticate(user=authed_user)
23+
return api
24+
25+
26+
@pytest.fixture
27+
def other_user(db) -> OsmUser:
28+
return OsmUser.objects.create(osm_id=43, username="bob")
29+
30+
31+
def test_banner_list_is_public_and_hides_unstarted_banners(db):
32+
now = timezone.now()
33+
Banner.objects.create(message="live now", start_date=now - timedelta(days=1))
34+
Banner.objects.create(message="coming soon", start_date=now + timedelta(days=1))
35+
36+
response = APIClient().get(BANNERS_URL)
37+
38+
assert response.status_code == 200
39+
messages = [banner["message"] for banner in response.json()["results"]]
40+
assert messages == ["live now"]
41+
42+
43+
def test_banner_serializer_exposes_displayable_state(db):
44+
now = timezone.now()
45+
Banner.objects.create(
46+
message="expired",
47+
start_date=now - timedelta(days=2),
48+
end_date=now - timedelta(days=1),
49+
)
50+
51+
response = APIClient().get(BANNERS_URL)
52+
53+
assert response.status_code == 200
54+
banners = response.json()["results"]
55+
assert len(banners) == 1
56+
# Started-but-expired banners are still listed, flagged not displayable
57+
assert banners[0]["is_displayable"] is False
58+
59+
60+
def test_notifications_require_authentication(db):
61+
response = APIClient().get(NOTIFICATIONS_URL)
62+
assert response.status_code == 401
63+
64+
65+
def test_notifications_list_only_own(client, authed_user, other_user):
66+
UserNotification.objects.create(user=authed_user, message="for alice")
67+
UserNotification.objects.create(user=other_user, message="for bob")
68+
69+
response = client.get(NOTIFICATIONS_URL)
70+
71+
assert response.status_code == 200
72+
payload = response.json()
73+
assert payload["count"] == 1
74+
assert payload["results"][0]["message"] == "for alice"
75+
assert payload["results"][0]["is_read"] is False
76+
77+
78+
def test_mark_read_sets_read_state(client, authed_user):
79+
notification = UserNotification.objects.create(user=authed_user, message="hi")
80+
81+
response = client.post(f"{NOTIFICATIONS_URL}{notification.id}/mark-read/")
82+
83+
assert response.status_code == 200
84+
body = response.json()
85+
assert body["is_read"] is True
86+
assert body["read_at"] is not None
87+
notification.refresh_from_db()
88+
assert notification.is_read is True
89+
assert notification.read_at is not None
90+
91+
92+
def test_mark_read_scoped_to_own_notifications(client, other_user):
93+
notification = UserNotification.objects.create(user=other_user, message="not yours")
94+
95+
response = client.post(f"{NOTIFICATIONS_URL}{notification.id}/mark-read/")
96+
97+
assert response.status_code == 404
98+
notification.refresh_from_db()
99+
assert notification.is_read is False
100+
101+
102+
def test_mark_all_read_updates_only_own_unread(client, authed_user, other_user):
103+
UserNotification.objects.create(user=authed_user, message="one")
104+
UserNotification.objects.create(user=authed_user, message="two")
105+
other_notification = UserNotification.objects.create(user=other_user, message="other")
106+
107+
response = client.post(f"{NOTIFICATIONS_URL}mark-all-read/")
108+
109+
assert response.status_code == 200
110+
assert response.json() == {"detail": "ok"}
111+
assert UserNotification.objects.filter(user=authed_user, is_read=False).count() == 0
112+
other_notification.refresh_from_db()
113+
assert other_notification.is_read is False
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import pytest
2+
from rest_framework.test import APIClient
3+
4+
from accounts.models import OsmUser
5+
from stars.models import Star
6+
7+
STARS_URL = "/api/v1/stars/"
8+
9+
10+
@pytest.fixture
11+
def authed_user(db) -> OsmUser:
12+
return OsmUser.objects.create(osm_id=42, username="alice")
13+
14+
15+
@pytest.fixture
16+
def client(authed_user: OsmUser) -> APIClient:
17+
api = APIClient()
18+
api.force_authenticate(user=authed_user)
19+
return api
20+
21+
22+
@pytest.fixture
23+
def anon_client(db) -> APIClient:
24+
return APIClient()
25+
26+
27+
def test_star_get_requires_target_id(anon_client):
28+
response = anon_client.get(STARS_URL)
29+
assert response.status_code == 400
30+
assert "target_id" in response.json()["detail"]
31+
32+
33+
def test_star_post_requires_target_id(anon_client):
34+
response = anon_client.post(STARS_URL)
35+
assert response.status_code == 400
36+
37+
38+
def test_anonymous_star_dedupes_repeat_clicks(anon_client):
39+
response = anon_client.post(f"{STARS_URL}?target_id=model-1")
40+
assert response.status_code == 201
41+
payload = response.json()
42+
assert payload == {
43+
"target_id": "model-1",
44+
"starred": True,
45+
"count": 1,
46+
"created": True,
47+
}
48+
49+
# Same client (same IP + user agent) starring again must not double-count
50+
response = anon_client.post(f"{STARS_URL}?target_id=model-1")
51+
assert response.status_code == 200
52+
payload = response.json()
53+
assert payload["created"] is False
54+
assert payload["count"] == 1
55+
56+
57+
def test_distinct_anonymous_clients_count_separately(anon_client):
58+
anon_client.post(f"{STARS_URL}?target_id=model-1")
59+
response = anon_client.post(
60+
f"{STARS_URL}?target_id=model-1",
61+
HTTP_USER_AGENT="a-different-browser",
62+
)
63+
64+
assert response.status_code == 201
65+
assert response.json()["count"] == 2
66+
67+
68+
def test_authenticated_star_state(client, authed_user, anon_client):
69+
response = client.post(f"{STARS_URL}?target_id=model-2")
70+
assert response.status_code == 201
71+
assert Star.objects.get(target_id="model-2").user == authed_user
72+
73+
response = client.get(STARS_URL, {"target_id": "model-2"})
74+
assert response.status_code == 200
75+
assert response.json() == {"target_id": "model-2", "count": 1, "starred": True}
76+
77+
# An anonymous viewer sees the count but is not the one who starred
78+
response = anon_client.get(STARS_URL, {"target_id": "model-2"})
79+
assert response.status_code == 200
80+
assert response.json() == {"target_id": "model-2", "count": 1, "starred": False}
81+
82+
83+
def test_authenticated_unstar(client):
84+
client.post(f"{STARS_URL}?target_id=model-3")
85+
86+
response = client.delete(f"{STARS_URL}?target_id=model-3")
87+
assert response.status_code == 204
88+
89+
response = client.get(STARS_URL, {"target_id": "model-3"})
90+
assert response.json() == {"target_id": "model-3", "count": 0, "starred": False}
91+
92+
93+
def test_anonymous_unstar_removes_only_own_star(client, anon_client):
94+
client.post(f"{STARS_URL}?target_id=model-4")
95+
anon_client.post(f"{STARS_URL}?target_id=model-4")
96+
assert Star.objects.filter(target_id="model-4").count() == 2
97+
98+
response = anon_client.delete(f"{STARS_URL}?target_id=model-4")
99+
assert response.status_code == 204
100+
101+
response = anon_client.get(STARS_URL, {"target_id": "model-4"})
102+
assert response.json() == {"target_id": "model-4", "count": 1, "starred": False}

0 commit comments

Comments
 (0)