diff --git a/backend/routers/medicines.py b/backend/routers/medicines.py index b55fee7..b91be08 100644 --- a/backend/routers/medicines.py +++ b/backend/routers/medicines.py @@ -2,29 +2,49 @@ from pydantic import BaseModel from typing import List, Optional from database import supabase +from functools import lru_cache +import math import random import time import urllib.request import json -LAST_IP_LAT = 18.5204 -LAST_IP_LON = 73.8567 -IP_FETCHED = False +DEFAULT_LOCATION = (18.5204, 73.8567) +IP_LOCATION_URL = "https://ipwho.is/" + + +def _parse_location(data): + if data.get("success") is False: + raise ValueError("IP location lookup was not successful") + lat = float(data.get("latitude", data.get("lat"))) + lon = float(data.get("longitude", data.get("lon"))) + if not ( + math.isfinite(lat) + and math.isfinite(lon) + and -90 <= lat <= 90 + and -180 <= lon <= 180 + ): + raise ValueError("IP location response contains invalid coordinates") + return lat, lon + + +@lru_cache(maxsize=1) +def _fetch_ip_location(): + req = urllib.request.Request( + IP_LOCATION_URL, + headers={'User-Agent': 'Mozilla/5.0'} + ) + with urllib.request.urlopen(req, timeout=2) as response: + data = json.loads(response.read().decode()) + return _parse_location(data) + def get_default_location(): - global LAST_IP_LAT, LAST_IP_LON, IP_FETCHED - if IP_FETCHED: - return LAST_IP_LAT, LAST_IP_LON try: - req = urllib.request.Request("http://ip-api.com/json/", headers={'User-Agent': 'Mozilla/5.0'}) - with urllib.request.urlopen(req, timeout=2) as response: - data = json.loads(response.read().decode()) - LAST_IP_LAT = data["lat"] - LAST_IP_LON = data["lon"] - IP_FETCHED = True - return LAST_IP_LAT, LAST_IP_LON + return _fetch_ip_location() except Exception: - return 18.5204, 73.8567 + return DEFAULT_LOCATION + def fetch_real_pharmacies(lat, lon): import urllib.parse diff --git a/backend/tests/test_medicines.py b/backend/tests/test_medicines.py new file mode 100644 index 0000000..6af2827 --- /dev/null +++ b/backend/tests/test_medicines.py @@ -0,0 +1,112 @@ +import importlib +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + + +BACKEND_DIR = Path(__file__).resolve().parents[1] +BACKEND_DIR_STR = str(BACKEND_DIR) +ADDED_BACKEND_PATH = BACKEND_DIR_STR not in sys.path +if ADDED_BACKEND_PATH: + sys.path.insert(0, BACKEND_DIR_STR) + + +def _cleanup_import_state(): + sys.modules.pop("routers.medicines", None) + if ADDED_BACKEND_PATH and BACKEND_DIR_STR in sys.path: + sys.path.remove(BACKEND_DIR_STR) + + +unittest.addModuleCleanup(_cleanup_import_state) + + +class _APIRouter: + def get(self, *args, **kwargs): + def decorator(func): + return func + + return decorator + + +class _HTTPException(Exception): + pass + + +class _BaseModel: + pass + + +fastapi_module = types.ModuleType("fastapi") +fastapi_module.APIRouter = _APIRouter +fastapi_module.HTTPException = _HTTPException + +pydantic_module = types.ModuleType("pydantic") +pydantic_module.BaseModel = _BaseModel + +database_module = types.ModuleType("database") +database_module.supabase = object() + +with mock.patch.dict( + sys.modules, + { + "fastapi": fastapi_module, + "pydantic": pydantic_module, + "database": database_module, + }, +): + medicines = importlib.import_module("routers.medicines") + + +class DefaultLocationCacheTest(unittest.TestCase): + def setUp(self): + medicines._fetch_ip_location.cache_clear() + + def test_dependency_stubs_are_scoped_to_import(self): + self.assertIsNot(sys.modules.get("fastapi"), fastapi_module) + self.assertIsNot(sys.modules.get("pydantic"), pydantic_module) + + def test_default_location_fetch_is_cached_without_mutable_globals(self): + response = mock.Mock() + response.read.return_value = b'{"latitude": 12.34, "longitude": 56.78}' + + with mock.patch.object(medicines.urllib.request, "urlopen") as urlopen: + urlopen.return_value.__enter__.return_value = response + + self.assertEqual(medicines.get_default_location(), (12.34, 56.78)) + self.assertEqual(medicines.get_default_location(), (12.34, 56.78)) + + self.assertEqual(urlopen.call_count, 1) + self.assertFalse(hasattr(medicines, "LAST_IP_LAT")) + self.assertFalse(hasattr(medicines, "LAST_IP_LON")) + self.assertFalse(hasattr(medicines, "IP_FETCHED")) + + def test_default_location_falls_back_without_caching_failure(self): + response = mock.Mock() + response.read.return_value = b'{"latitude": 23.45, "longitude": 67.89}' + successful_lookup = mock.MagicMock() + successful_lookup.__enter__.return_value = response + + with mock.patch.object( + medicines.urllib.request, + "urlopen", + side_effect=[OSError("network unavailable"), successful_lookup], + ) as urlopen: + self.assertEqual(medicines.get_default_location(), medicines.DEFAULT_LOCATION) + self.assertEqual(medicines.get_default_location(), (23.45, 67.89)) + + self.assertEqual(urlopen.call_count, 2) + + def test_default_location_rejects_invalid_coordinates(self): + response = mock.Mock() + response.read.return_value = b'{"latitude": 1000, "longitude": 67.89}' + + with mock.patch.object(medicines.urllib.request, "urlopen") as urlopen: + urlopen.return_value.__enter__.return_value = response + + self.assertEqual(medicines.get_default_location(), medicines.DEFAULT_LOCATION) + + +if __name__ == "__main__": + unittest.main()