NeighbourAid / tests /test_endpoints_extras.py
Parth Kansal
commit
49e9f9d
"""Integration tests for the new endpoints added in the latest update:
* GET /api/alerts/{id}/photos (lazy-load)
* POST /api/alerts/{id}/flag (community moderation)
* PATCH /api/users/me/profile (skills / vehicle / contacts)
These all go through the same FastAPI app + mock-Mongo plumbing as the
existing endpoint tests so the routing/auth wiring is exercised end-to-end.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from bson import ObjectId
from app.core.security import create_token
def _token(role: str = "reporter", sub: str | None = None) -> str:
return create_token({"sub": sub or str(ObjectId()), "role": role})
# -----------------------------------------------------------------------
# /api/alerts/{id}/photos — lazy-loaded photo payload
# -----------------------------------------------------------------------
@pytest.mark.asyncio
async def test_photos_endpoint_returns_data_urls(client):
c, db = client
alert_id = ObjectId()
db.alerts.find_one = AsyncMock(
return_value={
"_id": alert_id,
"photos": ["data:image/jpeg;base64,xxx", "data:image/jpeg;base64,yyy"],
"flags": 0,
}
)
resp = await c.get(f"/api/alerts/{alert_id}/photos")
assert resp.status_code == 200
body = resp.json()
assert len(body["photos"]) == 2
assert body["photos"][0].startswith("data:image/")
@pytest.mark.asyncio
async def test_photos_endpoint_404_on_missing(client):
c, db = client
db.alerts.find_one = AsyncMock(return_value=None)
resp = await c.get(f"/api/alerts/{ObjectId()}/photos")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_photos_endpoint_hides_flagged(client):
c, db = client
db.alerts.find_one = AsyncMock(
return_value={"_id": ObjectId(), "photos": ["x"], "flags": 99}
)
resp = await c.get(f"/api/alerts/{ObjectId()}/photos")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_photos_endpoint_400_on_invalid_id(client):
c, _ = client
resp = await c.get("/api/alerts/not-a-real-id/photos")
assert resp.status_code == 400
# -----------------------------------------------------------------------
# /api/alerts/{id}/flag — community moderation
# -----------------------------------------------------------------------
@pytest.mark.asyncio
async def test_flag_requires_auth(client):
c, _ = client
resp = await c.post(f"/api/alerts/{ObjectId()}/flag")
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_flag_rejects_self_flag(client):
"""A reporter can't flag their own alert."""
c, db = client
user_id = str(ObjectId())
token = _token("reporter", sub=user_id)
db.alerts.find_one = AsyncMock(
return_value={
"_id": ObjectId(),
"reporter_id": ObjectId(user_id),
"flagged_by": [],
"flags": 0,
}
)
resp = await c.post(
f"/api/alerts/{ObjectId()}/flag",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_flag_idempotent_for_same_user(client):
c, db = client
user_id = str(ObjectId())
token = _token("reporter", sub=user_id)
db.alerts.find_one = AsyncMock(
return_value={
"_id": ObjectId(),
"reporter_id": ObjectId(), # different user
"flagged_by": [user_id],
"flags": 1,
}
)
resp = await c.post(
f"/api/alerts/{ObjectId()}/flag",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
body = resp.json()
assert body["already"] is True
assert body["flags"] == 1
@pytest.mark.asyncio
async def test_flag_increments_count(client):
c, db = client
user_id = str(ObjectId())
token = _token("reporter", sub=user_id)
db.alerts.find_one = AsyncMock(
return_value={
"_id": ObjectId(),
"reporter_id": ObjectId(),
"flagged_by": [],
"flags": 0,
}
)
db.alerts.find_one_and_update = AsyncMock(return_value={"flags": 1})
resp = await c.post(
f"/api/alerts/{ObjectId()}/flag",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
body = resp.json()
assert body["already"] is False
assert body["flags"] == 1
@pytest.mark.asyncio
async def test_flag_404_when_alert_gone(client):
c, db = client
db.alerts.find_one = AsyncMock(return_value=None)
resp = await c.post(
f"/api/alerts/{ObjectId()}/flag",
headers={"Authorization": f"Bearer {_token('reporter')}"},
)
assert resp.status_code == 404
# -----------------------------------------------------------------------
# /api/users/me/profile — patch skills / vehicle / contacts
# -----------------------------------------------------------------------
@pytest.mark.asyncio
async def test_profile_update_rejects_empty_body(client):
c, _ = client
resp = await c.patch(
"/api/users/me/profile",
json={}, # no fields → 400
headers={"Authorization": f"Bearer {_token('volunteer')}"},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_profile_update_rejects_unknown_skill(client):
c, _ = client
resp = await c.patch(
"/api/users/me/profile",
json={"skills": ["telepathy"]}, # not in the enum
headers={"Authorization": f"Bearer {_token('volunteer')}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_profile_update_writes_skills_only(client):
c, db = client
db.users.find_one_and_update = AsyncMock(
return_value={
"_id": ObjectId(),
"name": "x",
"email": "x@x.com",
"role": "volunteer",
"location": {"type": "Point", "coordinates": [76.7, 30.7]},
"skills": ["medical", "cpr"],
"has_vehicle": False,
"emergency_contacts": [],
"created_at": "2024-01-01T00:00:00",
}
)
resp = await c.patch(
"/api/users/me/profile",
json={"skills": ["medical", "cpr"]},
headers={"Authorization": f"Bearer {_token('volunteer')}"},
)
assert resp.status_code == 200
body = resp.json()
assert body["skills"] == ["medical", "cpr"]
# Verify the $set call only included the requested field
call = db.users.find_one_and_update.call_args
update = call.args[1] if len(call.args) >= 2 else call.kwargs.get("update")
assert "$set" in update
assert set(update["$set"].keys()) == {"skills"}
@pytest.mark.asyncio
async def test_profile_update_caps_emergency_contacts(client):
"""The model's max_length=5 should reject 6 contacts."""
c, _ = client
resp = await c.patch(
"/api/users/me/profile",
json={
"emergency_contacts": [
{"name": f"Contact {i}", "phone": "+91" + str(i) * 10}
for i in range(6)
]
},
headers={"Authorization": f"Bearer {_token('volunteer')}"},
)
assert resp.status_code == 422
# -----------------------------------------------------------------------
# Webhook service — fire-and-forget no-op when URL unset
# -----------------------------------------------------------------------
def test_webhook_no_op_when_url_unset(monkeypatch):
from app.core import config as cfg
from app.services import webhook
monkeypatch.setattr(cfg.settings, "ALERT_WEBHOOK_URL", "")
# Should not raise even though there's no event loop / asyncio context here
webhook.fire_alert_created({"id": "abc"})
def test_webhook_payload_shape_is_minimal():
from app.services.webhook import _webhook_payload
payload = _webhook_payload(
{
"id": "alert-1",
"category": "fire",
"urgency": "CRITICAL",
"description": "x",
"status": "open",
"address": "...",
"location": {"type": "Point", "coordinates": [76.7, 30.7]},
"photo_count": 1,
"verified_score": 80,
"created_at": "2026-04-25T11:23:00+00:00",
# These should NOT make it into the outbound payload
"photos": ["data:image/jpeg;base64,LARGEBLOB"],
"flagged_by": ["user-x"],
}
)
assert payload["event"] == "alert.created"
assert "photos" not in payload["alert"]
assert "flagged_by" not in payload["alert"]
assert payload["alert"]["photo_count"] == 1