From 78c368f5abebe2a58c7e2222b6808be2125f8e0d Mon Sep 17 00:00:00 2001 From: "Payalrvs0310@gmail.com" Date: Mon, 8 Jun 2026 12:28:45 +0530 Subject: [PATCH] fix(tests): repair 5 CI-ignored test files Signed-off-by: Payalrvs0310@gmail.com --- .github/workflows/ci.yml | 7 +- backend/cache_service.py | 11 +++ backend/requirements.txt | 129 ++++++++++++++++-------- tests/conftest.py | 9 +- tests/test_env_validation.py | 113 ++++++++------------- tests/test_llm.py | 187 ++++++++++++++++++++++++++++------- tests/test_purchase_links.py | 25 ++--- 7 files changed, 307 insertions(+), 174 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfe0d38b3..a7e767cd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: pip install torch --index-url https://download.pytorch.org/whl/cpu pip install -r backend/requirements.txt pip install pytest pytest-cov - pip install flask-wtf bleach Pillow beautifulsoup4 "pydantic[email]" + pip install flask-wtf bleach Pillow beautifulsoup4 flask-caching "pydantic[email]" - name: Run pytest env: @@ -57,10 +57,5 @@ jobs: PYTHONPATH: ".:backend" run: | python -m pytest tests/ \ - --ignore=tests/test_llm.py \ --ignore=tests/test_mood_improvements.py \ - --ignore=tests/test_mood_cache_db.py \ - --ignore=tests/test_category_cache.py \ - --ignore=tests/test_env_validation.py \ - --ignore=tests/test_purchase_links.py \ -v --tb=short --cov=backend \ No newline at end of file diff --git a/backend/cache_service.py b/backend/cache_service.py index 242c46c17..0316a5f24 100644 --- a/backend/cache_service.py +++ b/backend/cache_service.py @@ -23,6 +23,10 @@ except ImportError: REDIS_AVAILABLE = False REDIS_EXCEPTIONS = () +try: + from flask_caching import Cache +except ImportError: + Cache = None def with_redis_retry(): """Retry decorator for Redis transient failures.""" @@ -193,6 +197,13 @@ def init_app(self, app): else: cache_config['CACHE_TYPE'] = 'SimpleCache' logger.info("Using simple in-memory cache (not recommended for production)") + + if Cache is None: + logger.warning( + "flask-caching is not installed; caching is disabled. " + "Add flask-caching to requirements.txt." + ) + return self.cache = Cache() self.cache.init_app(app, config=cache_config) diff --git a/backend/requirements.txt b/backend/requirements.txt index 8b00a23b3..81bd2ef4f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,43 +1,86 @@ -Flask -flask-cors -Flask-JWT-Extended -Flask-Limiter -Flask-Migrate -Flask-SQLAlchemy -SQLAlchemy -numpy -pandas -scikit-learn -scipy -sentence-transformers -transformers -torch -tokenizers -huggingface_hub -safetensors -textblob -nltk -pydantic -python-dotenv -requests -Werkzeug -Jinja2 -click -itsdangerous -MarkupSafe -joblib -threadpoolctl -typing_extensions -packaging -PyYAML -regex -tqdm -filelock -fsspec -networkx -sympy -textblob==0.19.0 -tenacity -python-magic - -flask-socketio==5.3.6 +Flask + +flask-cors + +Flask-JWT-Extended + +Flask-Limiter + +Flask-Migrate + +Flask-SQLAlchemy + +SQLAlchemy + +numpy + +pandas + +scikit-learn + +scipy + +sentence-transformers + +transformers + +torch + +tokenizers + +huggingface_hub + +safetensors + +textblob + +nltk + +pydantic + +python-dotenv + +requests + +Werkzeug + +Jinja2 + +click + +itsdangerous + +MarkupSafe + +joblib + +threadpoolctl + +typing_extensions + +packaging + +PyYAML + +regex + +tqdm + +filelock + +fsspec + +networkx + +sympy + +textblob==0.19.0 +tenacity + +python-magic + + +flask-socketio==5.3.6 +beautifulsoup4 +flask-caching +vaderSentiment diff --git a/tests/conftest.py b/tests/conftest.py index afb465945..846a4839d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,8 +19,15 @@ # Stub out heavy optional packages so tests don't need GPU/model downloads for _mod in [ + # Windows / platform-specific + "magic", + "spine_generator", + # Mood analysis stack + "vaderSentiment", + "vaderSentiment.vaderSentiment", + # ML / NLP stack (already present, keeping for clarity) "transformers", "sentence_transformers", - "nltk", "nltk.tokenize", "nltk.tokenize.api", + "nltk", "nltk.corpus", "nltk.tokenize", "nltk.tokenize.api", "huggingface_hub", "safetensors", "tokenizers", "tqdm", "joblib", "textblob", "textblob.blob", "textblob.base", ]: diff --git a/tests/test_env_validation.py b/tests/test_env_validation.py index 9843d135e..7a6a40e3d 100644 --- a/tests/test_env_validation.py +++ b/tests/test_env_validation.py @@ -22,31 +22,17 @@ class TestEnvironmentValidation: """Test suite for environment variable validation.""" - @pytest.fixture(autouse=True) - def cleanup_env(self): - """Clean up environment variables before and after each test.""" - # Save original environment - original_env = os.environ.copy() - yield - # Restore original environment - os.environ.clear() - os.environ.update(original_env) - - def test_validate_required_env_vars_success(self): + def test_validate_required_env_vars_success(self, monkeypatch): """ Test Case 1: App starts successfully when all required variables are set. Expected: No ValueError raised, validation passes """ # Set all required variables - os.environ['JWT_SECRET_KEY'] = 'test-secret-key-at-least-32-characters-long-here' - os.environ['GOOGLE_BOOKS_API_KEY'] = 'test-google-books-key' - os.environ['DATABASE_URL'] = 'sqlite:///test.db' - - # Import config module (will create app_config) - # We need to reload the config module to pick up the new environment - if 'config' in sys.modules: - del sys.modules['config'] + monkeypatch.setenv('JWT_SECRET_KEY', 'test-secret-key-at-least-32-characters-long-here') + monkeypatch.setenv('GOOGLE_BOOKS_API_KEY', 'test-google-books-key') + monkeypatch.setenv('DATABASE_URL', 'sqlite:///test.db') + monkeypatch.delitem(sys.modules, 'config', raising=False) from config import validate_required_env_vars @@ -56,19 +42,17 @@ def test_validate_required_env_vars_success(self): except ValueError as e: pytest.fail(f"Validation should not fail with all variables set: {e}") - def test_validate_missing_jwt_secret_key(self): + def test_validate_missing_jwt_secret_key(self, monkeypatch): """ Test Case 2a: Starting app without JWT_SECRET_KEY throws ValueError. Expected: ValueError raised with clear message about missing JWT_SECRET_KEY """ # Clear JWT_SECRET_KEY - os.environ.pop('JWT_SECRET_KEY', None) - os.environ['GOOGLE_BOOKS_API_KEY'] = 'test-key' - os.environ['DATABASE_URL'] = 'sqlite:///test.db' - - if 'config' in sys.modules: - del sys.modules['config'] + monkeypatch.delenv('JWT_SECRET_KEY', raising=False) + monkeypatch.setenv('GOOGLE_BOOKS_API_KEY', 'test-key') + monkeypatch.setenv('DATABASE_URL', 'sqlite:///test.db') + monkeypatch.delitem(sys.modules, 'config', raising=False) from config import validate_required_env_vars @@ -78,19 +62,17 @@ def test_validate_missing_jwt_secret_key(self): assert 'JWT_SECRET_KEY' in str(exc_info.value) assert 'Missing or invalid' in str(exc_info.value) - def test_validate_missing_google_books_key(self): + def test_validate_missing_google_books_key(self, monkeypatch): """ Test Case 2b: Starting app without GOOGLE_BOOKS_API_KEY throws ValueError. Expected: ValueError raised with clear message about missing GOOGLE_BOOKS_API_KEY """ # Clear GOOGLE_BOOKS_API_KEY - os.environ['JWT_SECRET_KEY'] = 'test-secret-key-at-least-32-characters-long-here' - os.environ.pop('GOOGLE_BOOKS_API_KEY', None) - os.environ['DATABASE_URL'] = 'sqlite:///test.db' - - if 'config' in sys.modules: - del sys.modules['config'] + monkeypatch.setenv('JWT_SECRET_KEY', 'test-secret-key-at-least-32-characters-long-here') + monkeypatch.delenv('GOOGLE_BOOKS_API_KEY', raising=False) + monkeypatch.setenv('DATABASE_URL', 'sqlite:///test.db') + monkeypatch.delitem(sys.modules, 'config', raising=False) from config import validate_required_env_vars @@ -100,20 +82,18 @@ def test_validate_missing_google_books_key(self): assert 'GOOGLE_BOOKS_API_KEY' in str(exc_info.value) assert 'Missing or invalid' in str(exc_info.value) - def test_validate_missing_database_url(self): + def test_validate_missing_database_url(self, monkeypatch): """ Test Case 2c: Starting app without DATABASE_URL throws ValueError. Expected: ValueError raised with clear message about missing DATABASE_URL """ # Clear DATABASE_URL - os.environ['JWT_SECRET_KEY'] = 'test-secret-key-at-least-32-characters-long-here' - os.environ['GOOGLE_BOOKS_API_KEY'] = 'test-key' - os.environ.pop('DATABASE_URL', None) - - if 'config' in sys.modules: - del sys.modules['config'] - + monkeypatch.setenv('JWT_SECRET_KEY', 'test-secret-key-at-least-32-characters-long-here') + monkeypatch.setenv('GOOGLE_BOOKS_API_KEY', 'test-key') + monkeypatch.delenv('DATABASE_URL', raising=False) + monkeypatch.delitem(sys.modules, 'config', raising=False) + from config import validate_required_env_vars with pytest.raises(ValueError) as exc_info: @@ -121,18 +101,16 @@ def test_validate_missing_database_url(self): assert 'DATABASE_URL' in str(exc_info.value) - def test_validate_placeholder_values_rejected(self): + def test_validate_placeholder_values_rejected(self, monkeypatch): """ Test Case 3: Placeholder values (your-*) are rejected as invalid. Expected: ValueError raised when placeholder values are detected """ - os.environ['JWT_SECRET_KEY'] = 'your-super-secret-jwt-key-here' - os.environ['GOOGLE_BOOKS_API_KEY'] = 'your-google-books-api-key' - os.environ['DATABASE_URL'] = 'sqlite:///test.db' - - if 'config' in sys.modules: - del sys.modules['config'] + monkeypatch.setenv('JWT_SECRET_KEY', 'your-super-secret-jwt-key-here') + monkeypatch.setenv('GOOGLE_BOOKS_API_KEY', 'your-google-books-api-key') + monkeypatch.setenv('DATABASE_URL', 'sqlite:///test.db') + monkeypatch.delitem(sys.modules, 'config', raising=False) from config import validate_required_env_vars @@ -143,7 +121,7 @@ def test_validate_placeholder_values_rejected(self): # Should detect at least JWT_SECRET_KEY or GOOGLE_BOOKS_API_KEY as invalid assert 'Missing or invalid' in error_msg - def test_validate_error_message_clarity(self): + def test_validate_error_message_clarity(self, monkeypatch): """ Test Case 4: Error messages are clear and actionable. @@ -152,12 +130,10 @@ def test_validate_error_message_clarity(self): - What they are used for - Hint to check .env file """ - os.environ.pop('JWT_SECRET_KEY', None) - os.environ.pop('GOOGLE_BOOKS_API_KEY', None) - os.environ['DATABASE_URL'] = 'sqlite:///test.db' - - if 'config' in sys.modules: - del sys.modules['config'] + monkeypatch.delenv('JWT_SECRET_KEY', raising=False) + monkeypatch.delenv('GOOGLE_BOOKS_API_KEY', raising=False) + monkeypatch.setenv('DATABASE_URL', 'sqlite:///test.db') + monkeypatch.delitem(sys.modules, 'config', raising=False) from config import validate_required_env_vars @@ -171,18 +147,16 @@ def test_validate_error_message_clarity(self): assert 'GOOGLE_BOOKS_API_KEY' in error_msg assert '.env' in error_msg or 'environment' in error_msg.lower() - def test_config_validate_method(self): + def test_config_validate_method(self, monkeypatch): """ Test the Config.validate() method returns proper tuple format. Expected: Returns (bool, list) where bool is validity status """ - os.environ['JWT_SECRET_KEY'] = 'test-secret-key-at-least-32-characters-long-here' - os.environ['GOOGLE_BOOKS_API_KEY'] = 'test-key' - os.environ['DATABASE_URL'] = 'sqlite:///test.db' - - if 'config' in sys.modules: - del sys.modules['config'] + monkeypatch.setenv('JWT_SECRET_KEY', 'test-secret-key-at-least-32-characters-long-here') + monkeypatch.setenv('GOOGLE_BOOKS_API_KEY', 'test-key') + monkeypatch.setenv('DATABASE_URL', 'sqlite:///test.db') + monkeypatch.delitem(sys.modules, 'config', raising=False) from config import get_config config = get_config() @@ -224,21 +198,18 @@ def test_app_rejects_invalid_env(self): "The validation logic is tested in unit tests." ) - def test_app_accepts_valid_env(self): + def test_app_accepts_valid_env(self, monkeypatch): """ Integration Test 2: Full app startup succeeds with valid environment. Expected: App imports successfully when environment is valid """ # Use development environment defaults plus required variables - os.environ['JWT_SECRET_KEY'] = 'test-secret-key-at-least-32-characters-long-here' - os.environ['GOOGLE_BOOKS_API_KEY'] = 'test-key' - os.environ['DATABASE_URL'] = 'sqlite:///:memory:' - os.environ['APP_ENV'] = 'testing' - - # Try to import config - should work - if 'config' in sys.modules: - del sys.modules['config'] + monkeypatch.setenv('JWT_SECRET_KEY', 'test-secret-key-at-least-32-characters-long-here') + monkeypatch.setenv('GOOGLE_BOOKS_API_KEY', 'test-key') + monkeypatch.setenv('DATABASE_URL', 'sqlite:///:memory:') + monkeypatch.setenv('APP_ENV', 'testing') + monkeypatch.delitem(sys.modules, 'config', raising=False) from config import validate_required_env_vars diff --git a/tests/test_llm.py b/tests/test_llm.py index 58404b907..4210e7c47 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -1,36 +1,151 @@ -from dotenv import load_dotenv -import os - -# Load environment variables -loaded = load_dotenv() -print(f".env loaded: {loaded}") - -if os.path.exists(".env"): - print(".env file exists") - with open(".env", "r") as f: - content = f.read() - print(f".env file size: {len(content)} bytes") -else: - print(".env file DOES NOT EXIST") - -from ai_service import llm_service - -print("Testing LLM Service Setup...") - -# Debug environment variables -print(f"OPENAI_API_KEY present: {'OPENAI_API_KEY' in os.environ}") -print(f"GROQ_API_KEY present: {'GROQ_API_KEY' in os.environ}") -print(f"GEMINI_API_KEY present: {'GEMINI_API_KEY' in os.environ}") - -if llm_service.is_available(): - print("LLM Service is available!") - if llm_service.openai_client: - print("OpenAI client initialized.") - if llm_service.groq_client: - print("Groq client initialized.") - if llm_service.gemini_client: - print("Gemini client initialized.") -else: - print("LLM Service is NOT available. Please check your API keys.") - -print("\nDone.") \ No newline at end of file +""" +tests/test_llm.py + +Proper pytest test suite for the LLMService in backend/ai_service.py. +All external LLM calls are mocked — no real API keys required. +""" + +import pytest +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_service(openai=None, groq=None, gemini=None): + """ + Return a bare LLMService instance with clients pre-set without + triggering the real _setup_* methods (which read live API keys). + """ + from ai_service import LLMService + svc = LLMService.__new__(LLMService) + svc.openai_client = openai + svc.groq_client = groq + svc.gemini_client = gemini + svc.preferred_llm = 'groq' + svc.config = { + 'openai_model': 'gpt-3.5-turbo', + 'openai_temperature': 0.7, + 'openai_max_tokens': 500, + 'groq_model': 'llama-3.1-8b-instant', + 'groq_temperature': 0.7, + 'groq_max_tokens': 500, + 'gemini_model': 'models/gemini-2.0-flash-lite', + 'gemini_temperature': 0.7, + 'gemini_max_tokens': 500, + 'default_max_tokens': 150, + 'book_note_max_tokens': 400, + 'recommendation_max_tokens': 150, + 'category_books_max_tokens': 600, + 'test_max_tokens': 10, + } + return svc + + +# --------------------------------------------------------------------------- +# is_available() +# --------------------------------------------------------------------------- + +class TestIsAvailable: + def test_returns_false_when_no_clients(self): + svc = _make_service() + assert svc.is_available() is False + + def test_returns_true_with_groq_client(self): + svc = _make_service(groq=MagicMock()) + assert svc.is_available() is True + + def test_returns_true_with_openai_client(self): + svc = _make_service(openai=True) + assert svc.is_available() is True + + def test_returns_true_with_gemini_client(self): + svc = _make_service(gemini=MagicMock()) + assert svc.is_available() is True + + def test_returns_true_when_all_clients_set(self): + svc = _make_service(openai=True, groq=MagicMock(), gemini=MagicMock()) + assert svc.is_available() is True + + +# --------------------------------------------------------------------------- +# generate_text() +# --------------------------------------------------------------------------- + +class TestGenerateText: + def test_returns_none_when_no_service_available(self): + svc = _make_service() + result = svc.generate_text("hello") + assert result is None + + def test_delegates_to_groq_when_preferred(self): + svc = _make_service(groq=MagicMock()) + svc.preferred_llm = 'groq' + + with patch.object(svc, '_generate_with_groq', return_value='groq response') as mock_groq: + result = svc.generate_text("test prompt") + + assert result == 'groq response' + mock_groq.assert_called_once() + + def test_falls_back_to_groq_when_preferred_unavailable(self): + """preferred=openai but openai is None → falls back to groq.""" + svc = _make_service(groq=MagicMock()) + svc.preferred_llm = 'openai' # preferred, but openai_client is None + + with patch.object(svc, '_generate_with_groq', return_value='fallback') as mock_groq: + result = svc.generate_text("fallback test") + + assert result == 'fallback' + mock_groq.assert_called_once() + + def test_uses_default_max_tokens_when_none_given(self): + svc = _make_service(groq=MagicMock()) + + with patch.object(svc, '_generate_with_groq', return_value='ok') as mock_groq: + svc.generate_text("prompt") + + _, called_tokens = mock_groq.call_args[0] + assert called_tokens == svc.config['default_max_tokens'] + + def test_respects_explicit_max_tokens(self): + svc = _make_service(groq=MagicMock()) + + with patch.object(svc, '_generate_with_groq', return_value='ok') as mock_groq: + svc.generate_text("prompt", max_tokens=42) + + _, called_tokens = mock_groq.call_args[0] + assert called_tokens == 42 + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +class TestLLMServiceSingleton: + def test_llm_service_is_importable(self): + from ai_service import llm_service + assert llm_service is not None + + def test_llm_service_has_is_available(self): + from ai_service import llm_service + assert callable(llm_service.is_available) + + def test_llm_service_has_generate_text(self): + from ai_service import llm_service + assert callable(llm_service.generate_text) + + def test_llm_service_unavailable_without_real_keys(self, monkeypatch): + """ + In CI the keys are fake strings. The _setup_* methods create real + client objects only when the SDK accepts the key. For providers that + validate eagerly (OpenAI, Gemini) a fake key raises, so the client + stays None. Groq may or may not validate eagerly; we mock the import. + """ + with patch('ai_service.GROQ_AVAILABLE', False), \ + patch('ai_service.OPENAI_AVAILABLE', False), \ + patch('ai_service.GEMINI_AVAILABLE', False): + from ai_service import LLMService + svc = LLMService() + assert svc.is_available() is False \ No newline at end of file diff --git a/tests/test_purchase_links.py b/tests/test_purchase_links.py index c6ed82a99..e829043cc 100644 --- a/tests/test_purchase_links.py +++ b/tests/test_purchase_links.py @@ -1,19 +1,8 @@ -import os -os.environ['APP_ENV'] = 'testing' import pytest import json -import sys from unittest.mock import patch -sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend')) -from app import app -@pytest.fixture -def client(): - app.config['TESTING'] = True - with app.test_client() as client: - yield client - def test_purchase_links_missing_title(client): """Test purchase-links endpoint with missing title parameter.""" response = client.get('/api/v1/books/purchase-links') @@ -23,6 +12,7 @@ def test_purchase_links_missing_title(client): assert 'error' in data assert data['error'] == 'Title is required' + @patch('purchase_links.purchase_manager.PurchaseManager.get_quick_links') def test_purchase_links_success(mock_get_quick_links, client): """Test purchase-links endpoint with valid parameters and mocked manager.""" @@ -33,20 +23,21 @@ def test_purchase_links_success(mock_get_quick_links, client): 'url': 'https://books.google.com/books?id=zyTCAlFPjgYC', 'available': True, 'color': '#EA4335', - 'icon': 'fa-solid fa-book' + 'icon': 'fa-solid fa-book', } ] - - response = client.get('/api/v1/books/purchase-links?title=Dune&author=Frank+Herbert&isbn=9780441172719') + + response = client.get( + '/api/v1/books/purchase-links?title=Dune&author=Frank+Herbert&isbn=9780441172719' + ) data = json.loads(response.data) - print("RESPONSE DATA:", data) assert response.status_code == 200 assert data['success'] is True assert 'links' in data - + links = data['links'] assert len(links) == 1 assert links[0]['name'] == 'Google Books' assert links[0]['url'] == 'https://books.google.com/books?id=zyTCAlFPjgYC' assert links[0]['color'] == '#EA4335' - assert links[0]['icon'] == 'fa-solid fa-book' + assert links[0]['icon'] == 'fa-solid fa-book' \ No newline at end of file