Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions backend/src/modules/location/location_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@
from src.core.models import PaginatedResponse


class AutocompleteInput(BaseModel):
# Input for address autocomplete
address: str


class AutocompleteResult(BaseModel):
# Result from Google Maps autocomplete
formatted_address: str
place_id: str


class AddressData(BaseModel):
# Location data without OCSL-specific fields
google_place_id: str
Expand Down Expand Up @@ -65,9 +76,3 @@ class LocationCreate(BaseModel):
warning_count: int = 0
citation_count: int = 0
hold_expiration: datetime | None = None


class AutocompleteResult(BaseModel):
# Result from Google Maps autocomplete
formatted_address: str
place_id: str
45 changes: 42 additions & 3 deletions backend/src/modules/location/location_router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from fastapi import APIRouter, Depends
from src.core.authentication import authenticate_admin, authenticate_staff_or_admin
from fastapi import APIRouter, Depends, HTTPException, status
from src.core.authentication import (
authenticate_admin,
authenticate_staff_or_admin,
authenticate_user,
)
from src.modules.account.account_model import Account
from src.modules.location.location_model import (
Location,
LocationCreate,
Expand All @@ -8,7 +13,41 @@
)
from src.modules.location.location_service import LocationService

location_router = APIRouter(prefix="/locations", tags=["locations"])
from .location_model import AutocompleteInput, AutocompleteResult

location_router = APIRouter(prefix="/api/locations", tags=["locations"])


@location_router.post(
"/autocomplete",
response_model=list[AutocompleteResult],
status_code=status.HTTP_200_OK,
summary="Autocomplete address search",
description="Returns address suggestions based on user input using Google Maps Places API.",
)
async def autocomplete_address(
input_data: AutocompleteInput,
location_service: LocationService = Depends(),
user: Account = Depends(authenticate_user),
) -> list[AutocompleteResult]:
"""
Autocomplete address search endpoint.
"""
try:
results = await location_service.autocomplete_address(input_data.address)
return results
except ValueError as e:
# Handle validation errors from service
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception:
# Log error in production
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch address suggestions. Please try again later.",
)


@location_router.get("/", response_model=PaginatedLocationResponse)
Expand Down
4 changes: 2 additions & 2 deletions backend/test/modules/location/location_router_crud_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def get_mock_location_service():
app.dependency_overrides[get_session] = override_get_session

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
transport=ASGITransport(app=app), base_url="http://test/api"
) as ac:
yield ac

Expand All @@ -64,7 +64,7 @@ def get_mock_location_service():

async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
base_url="http://test/api",
headers={"Authorization": "Bearer admin"},
) as ac:
yield ac
Expand Down
184 changes: 184 additions & 0 deletions backend/test/modules/location/location_router_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
from typing import Any
from unittest.mock import AsyncMock

import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from src.core.authentication import authenticate_user
from src.main import app
from src.modules.account.account_model import Account, AccountRole
from src.modules.location.location_model import AutocompleteResult
from src.modules.location.location_service import LocationService


@pytest_asyncio.fixture
async def mock_location_service():
"""Create a mock LocationService for testing"""
return AsyncMock(spec=LocationService)


@pytest_asyncio.fixture
async def override_dependencies(mock_location_service: AsyncMock):
"""Override dependencies to provide mock service and auth"""

async def _fake_user():
return Account(
id=1,
email="[email protected]",
first_name="Test",
last_name="User",
pid="123456789",
role=AccountRole.STUDENT,
)

def _get_mock_location_service():
return mock_location_service

app.dependency_overrides[authenticate_user] = _fake_user
app.dependency_overrides[LocationService] = _get_mock_location_service
yield
app.dependency_overrides.clear()


@pytest_asyncio.fixture
async def override_dependencies_no_auth(mock_location_service: AsyncMock):
"""Override dependencies without authentication"""

def _get_mock_location_service():
return mock_location_service

app.dependency_overrides[LocationService] = _get_mock_location_service
yield
app.dependency_overrides.clear()


@pytest.mark.asyncio
async def test_autocomplete_success(
override_dependencies: Any, mock_location_service: AsyncMock
):
"""Test that the endpoint returns multiple address suggestions successfully"""
mock_results = [
AutocompleteResult(
formatted_address="123 Main St, Chapel Hill, NC 27514, USA",
place_id="ChIJTest123",
),
AutocompleteResult(
formatted_address="123 Main St, Durham, NC 27701, USA",
place_id="ChIJTest456",
),
]
mock_location_service.autocomplete_address.return_value = mock_results

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post(
"/api/locations/autocomplete", json={"address": "123 Main St"}
)

assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["formatted_address"] == "123 Main St, Chapel Hill, NC 27514, USA"
assert data[0]["place_id"] == "ChIJTest123"
mock_location_service.autocomplete_address.assert_called_once_with(
"123 Main St"
)


@pytest.mark.asyncio
async def test_autocomplete_empty_results(
override_dependencies: Any, mock_location_service: AsyncMock
):
"""Test that the endpoint returns an empty list when no addresses match"""
mock_location_service.autocomplete_address.return_value = []

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post(
"/api/locations/autocomplete",
json={"address": "nonexistentaddress12345xyz"},
)

assert response.status_code == 200
assert response.json() == []


@pytest.mark.asyncio
async def test_autocomplete_missing_address(override_dependencies: Any):
"""Test that the endpoint returns 422 when address field is missing"""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/api/locations/autocomplete", json={})
assert response.status_code == 422


@pytest.mark.asyncio
async def test_autocomplete_empty_string(
override_dependencies: Any, mock_location_service: AsyncMock
):
"""Test that the endpoint handles empty string gracefully"""
mock_location_service.autocomplete_address.return_value = []

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post(
"/api/locations/autocomplete", json={"address": ""}
)
assert response.status_code == 200
assert response.json() == []


@pytest.mark.asyncio
async def test_autocomplete_unauthenticated(override_dependencies_no_auth: Any):
"""Test that the endpoint requires authentication"""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post(
"/api/locations/autocomplete", json={"address": "123 Main St"}
)
assert response.status_code == 401


@pytest.mark.asyncio
async def test_autocomplete_service_exception(
override_dependencies: Any, mock_location_service: AsyncMock
):
"""Test that service exceptions are handled correctly"""
mock_location_service.autocomplete_address.side_effect = Exception(
"Service temporarily unavailable"
)

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post(
"/api/locations/autocomplete",
json={"address": "123 Test St"},
)

assert response.status_code == 500


@pytest.mark.asyncio
async def test_autocomplete_value_error(
override_dependencies: Any, mock_location_service: AsyncMock
):
"""Test that ValueError from API is handled correctly"""
mock_location_service.autocomplete_address.side_effect = ValueError(
"Invalid API key provided"
)

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post(
"/api/locations/autocomplete",
json={"address": "123 Test St"},
)

assert response.status_code == 400
53 changes: 53 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.544.0",
"next": "15.5.4",
Expand Down
Loading