Skip to content

Commit 2ee25e5

Browse files
VidurShahnaasanov
andauthored
Address Autocomplete Component & Route #41 (#84)
* built out router, and autocomplete frontend component. also made a mild change in the script reset_dev.py so that contactpreference aligns with database name * the new files for the router/autocmoplete work * deleted test files and also updated ui. made input typing work * mild test fixes * fixed broken tests from merge * test fixes * added defualt constructor di and fixed ui * added default location service value for party reg form --------- Co-authored-by: Nick A <[email protected]>
1 parent 412eabe commit 2ee25e5

File tree

11 files changed

+1045
-38
lines changed

11 files changed

+1045
-38
lines changed

backend/src/modules/location/location_model.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@
55
from src.core.models import PaginatedResponse
66

77

8+
class AutocompleteInput(BaseModel):
9+
# Input for address autocomplete
10+
address: str
11+
12+
13+
class AutocompleteResult(BaseModel):
14+
# Result from Google Maps autocomplete
15+
formatted_address: str
16+
place_id: str
17+
18+
819
class AddressData(BaseModel):
920
# Location data without OCSL-specific fields
1021
google_place_id: str
@@ -65,9 +76,3 @@ class LocationCreate(BaseModel):
6576
warning_count: int = 0
6677
citation_count: int = 0
6778
hold_expiration: datetime | None = None
68-
69-
70-
class AutocompleteResult(BaseModel):
71-
# Result from Google Maps autocomplete
72-
formatted_address: str
73-
place_id: str

backend/src/modules/location/location_router.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
from fastapi import APIRouter, Depends
2-
from src.core.authentication import authenticate_admin, authenticate_staff_or_admin
1+
from fastapi import APIRouter, Depends, HTTPException, status
2+
from src.core.authentication import (
3+
authenticate_admin,
4+
authenticate_staff_or_admin,
5+
authenticate_user,
6+
)
7+
from src.modules.account.account_model import Account
38
from src.modules.location.location_model import (
49
Location,
510
LocationCreate,
@@ -8,7 +13,41 @@
813
)
914
from src.modules.location.location_service import LocationService
1015

11-
location_router = APIRouter(prefix="/locations", tags=["locations"])
16+
from .location_model import AutocompleteInput, AutocompleteResult
17+
18+
location_router = APIRouter(prefix="/api/locations", tags=["locations"])
19+
20+
21+
@location_router.post(
22+
"/autocomplete",
23+
response_model=list[AutocompleteResult],
24+
status_code=status.HTTP_200_OK,
25+
summary="Autocomplete address search",
26+
description="Returns address suggestions based on user input using Google Maps Places API.",
27+
)
28+
async def autocomplete_address(
29+
input_data: AutocompleteInput,
30+
location_service: LocationService = Depends(),
31+
user: Account = Depends(authenticate_user),
32+
) -> list[AutocompleteResult]:
33+
"""
34+
Autocomplete address search endpoint.
35+
"""
36+
try:
37+
results = await location_service.autocomplete_address(input_data.address)
38+
return results
39+
except ValueError as e:
40+
# Handle validation errors from service
41+
raise HTTPException(
42+
status_code=status.HTTP_400_BAD_REQUEST,
43+
detail=str(e),
44+
)
45+
except Exception:
46+
# Log error in production
47+
raise HTTPException(
48+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
49+
detail="Failed to fetch address suggestions. Please try again later.",
50+
)
1251

1352

1453
@location_router.get("/", response_model=PaginatedLocationResponse)

backend/test/modules/location/location_router_crud_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def get_mock_location_service():
4040
app.dependency_overrides[get_session] = override_get_session
4141

4242
async with AsyncClient(
43-
transport=ASGITransport(app=app), base_url="http://test"
43+
transport=ASGITransport(app=app), base_url="http://test/api"
4444
) as ac:
4545
yield ac
4646

@@ -64,7 +64,7 @@ def get_mock_location_service():
6464

6565
async with AsyncClient(
6666
transport=ASGITransport(app=app),
67-
base_url="http://test",
67+
base_url="http://test/api",
6868
headers={"Authorization": "Bearer admin"},
6969
) as ac:
7070
yield ac
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
from typing import Any
2+
from unittest.mock import AsyncMock
3+
4+
import pytest
5+
import pytest_asyncio
6+
from httpx import ASGITransport, AsyncClient
7+
from src.core.authentication import authenticate_user
8+
from src.main import app
9+
from src.modules.account.account_model import Account, AccountRole
10+
from src.modules.location.location_model import AutocompleteResult
11+
from src.modules.location.location_service import LocationService
12+
13+
14+
@pytest_asyncio.fixture
15+
async def mock_location_service():
16+
"""Create a mock LocationService for testing"""
17+
return AsyncMock(spec=LocationService)
18+
19+
20+
@pytest_asyncio.fixture
21+
async def override_dependencies(mock_location_service: AsyncMock):
22+
"""Override dependencies to provide mock service and auth"""
23+
24+
async def _fake_user():
25+
return Account(
26+
id=1,
27+
28+
first_name="Test",
29+
last_name="User",
30+
pid="123456789",
31+
role=AccountRole.STUDENT,
32+
)
33+
34+
def _get_mock_location_service():
35+
return mock_location_service
36+
37+
app.dependency_overrides[authenticate_user] = _fake_user
38+
app.dependency_overrides[LocationService] = _get_mock_location_service
39+
yield
40+
app.dependency_overrides.clear()
41+
42+
43+
@pytest_asyncio.fixture
44+
async def override_dependencies_no_auth(mock_location_service: AsyncMock):
45+
"""Override dependencies without authentication"""
46+
47+
def _get_mock_location_service():
48+
return mock_location_service
49+
50+
app.dependency_overrides[LocationService] = _get_mock_location_service
51+
yield
52+
app.dependency_overrides.clear()
53+
54+
55+
@pytest.mark.asyncio
56+
async def test_autocomplete_success(
57+
override_dependencies: Any, mock_location_service: AsyncMock
58+
):
59+
"""Test that the endpoint returns multiple address suggestions successfully"""
60+
mock_results = [
61+
AutocompleteResult(
62+
formatted_address="123 Main St, Chapel Hill, NC 27514, USA",
63+
place_id="ChIJTest123",
64+
),
65+
AutocompleteResult(
66+
formatted_address="123 Main St, Durham, NC 27701, USA",
67+
place_id="ChIJTest456",
68+
),
69+
]
70+
mock_location_service.autocomplete_address.return_value = mock_results
71+
72+
async with AsyncClient(
73+
transport=ASGITransport(app=app), base_url="http://test"
74+
) as client:
75+
response = await client.post(
76+
"/api/locations/autocomplete", json={"address": "123 Main St"}
77+
)
78+
79+
assert response.status_code == 200
80+
data = response.json()
81+
assert len(data) == 2
82+
assert data[0]["formatted_address"] == "123 Main St, Chapel Hill, NC 27514, USA"
83+
assert data[0]["place_id"] == "ChIJTest123"
84+
mock_location_service.autocomplete_address.assert_called_once_with(
85+
"123 Main St"
86+
)
87+
88+
89+
@pytest.mark.asyncio
90+
async def test_autocomplete_empty_results(
91+
override_dependencies: Any, mock_location_service: AsyncMock
92+
):
93+
"""Test that the endpoint returns an empty list when no addresses match"""
94+
mock_location_service.autocomplete_address.return_value = []
95+
96+
async with AsyncClient(
97+
transport=ASGITransport(app=app), base_url="http://test"
98+
) as client:
99+
response = await client.post(
100+
"/api/locations/autocomplete",
101+
json={"address": "nonexistentaddress12345xyz"},
102+
)
103+
104+
assert response.status_code == 200
105+
assert response.json() == []
106+
107+
108+
@pytest.mark.asyncio
109+
async def test_autocomplete_missing_address(override_dependencies: Any):
110+
"""Test that the endpoint returns 422 when address field is missing"""
111+
async with AsyncClient(
112+
transport=ASGITransport(app=app), base_url="http://test"
113+
) as client:
114+
response = await client.post("/api/locations/autocomplete", json={})
115+
assert response.status_code == 422
116+
117+
118+
@pytest.mark.asyncio
119+
async def test_autocomplete_empty_string(
120+
override_dependencies: Any, mock_location_service: AsyncMock
121+
):
122+
"""Test that the endpoint handles empty string gracefully"""
123+
mock_location_service.autocomplete_address.return_value = []
124+
125+
async with AsyncClient(
126+
transport=ASGITransport(app=app), base_url="http://test"
127+
) as client:
128+
response = await client.post(
129+
"/api/locations/autocomplete", json={"address": ""}
130+
)
131+
assert response.status_code == 200
132+
assert response.json() == []
133+
134+
135+
@pytest.mark.asyncio
136+
async def test_autocomplete_unauthenticated(override_dependencies_no_auth: Any):
137+
"""Test that the endpoint requires authentication"""
138+
async with AsyncClient(
139+
transport=ASGITransport(app=app), base_url="http://test"
140+
) as client:
141+
response = await client.post(
142+
"/api/locations/autocomplete", json={"address": "123 Main St"}
143+
)
144+
assert response.status_code == 401
145+
146+
147+
@pytest.mark.asyncio
148+
async def test_autocomplete_service_exception(
149+
override_dependencies: Any, mock_location_service: AsyncMock
150+
):
151+
"""Test that service exceptions are handled correctly"""
152+
mock_location_service.autocomplete_address.side_effect = Exception(
153+
"Service temporarily unavailable"
154+
)
155+
156+
async with AsyncClient(
157+
transport=ASGITransport(app=app), base_url="http://test"
158+
) as client:
159+
response = await client.post(
160+
"/api/locations/autocomplete",
161+
json={"address": "123 Test St"},
162+
)
163+
164+
assert response.status_code == 500
165+
166+
167+
@pytest.mark.asyncio
168+
async def test_autocomplete_value_error(
169+
override_dependencies: Any, mock_location_service: AsyncMock
170+
):
171+
"""Test that ValueError from API is handled correctly"""
172+
mock_location_service.autocomplete_address.side_effect = ValueError(
173+
"Invalid API key provided"
174+
)
175+
176+
async with AsyncClient(
177+
transport=ASGITransport(app=app), base_url="http://test"
178+
) as client:
179+
response = await client.post(
180+
"/api/locations/autocomplete",
181+
json={"address": "123 Test St"},
182+
)
183+
184+
assert response.status_code == 400

frontend/package-lock.json

Lines changed: 53 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"axios": "^1.12.2",
2424
"class-variance-authority": "^0.7.1",
2525
"clsx": "^2.1.1",
26+
"cmdk": "^1.1.1",
2627
"date-fns": "^4.1.0",
2728
"lucide-react": "^0.544.0",
2829
"next": "15.5.4",

0 commit comments

Comments
 (0)