diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..7d7dff6 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = unitTest*.py test_*.py *_test.py +python_classes = Test* +python_functions = test_* diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7ef8f56 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,170 @@ +""" +Shared pytest fixtures for b0bot test suite. + +All external dependencies (Pinecone, SentenceTransformers, HuggingFace, +LangChain) are stubbed here so tests run without API keys, GPU, or +network access. + +Design note — why sys.modules injection instead of patch(): + mock.patch("some.module.Class") requires the module to already be + importable. When sentence_transformers / langchain_community are not + installed, patch() itself raises ModuleNotFoundError before the test + body runs. The correct approach for uninstalled packages is to inject + a MagicMock into sys.modules BEFORE any project code imports them. +""" +import sys +import os +import types +import pytest +from unittest.mock import MagicMock, patch +import numpy as np + +# ── Ensure project root is on sys.path ────────────────────────────────────── +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +# ── Pre-inject stubs for packages that may not be installed ───────────────── +# This must happen at module level (before any test collection imports +# project code) — conftest.py is loaded by pytest before test files. + +def _stub_module(name: str) -> MagicMock: + """Create a MagicMock and register it under `name` in sys.modules.""" + mod = MagicMock(name=name) + sys.modules[name] = mod + return mod + + +# sentence_transformers ── only inject if not already installed +if "sentence_transformers" not in sys.modules: + _st = _stub_module("sentence_transformers") + _dense_stub = MagicMock() + _dense_stub.encode.return_value = np.zeros(384, dtype="float32") + _sparse_stub = MagicMock() + _sparse_stub.encode.return_value = np.array([0.0, 0.5] + [0.0] * 382) + _st.SentenceTransformer.return_value = _dense_stub + _st.SparseEncoder.return_value = _sparse_stub + +# langchain_community.llms — stub only the HuggingFaceEndpoint +if "langchain_community" not in sys.modules: + _lc = types.ModuleType("langchain_community") + _lc_llms = types.ModuleType("langchain_community.llms") + _lc_llms.HuggingFaceEndpoint = MagicMock(name="HuggingFaceEndpoint") + _lc.llms = _lc_llms + sys.modules["langchain_community"] = _lc + sys.modules["langchain_community.llms"] = _lc_llms + + +# ── Pinecone mock helpers ──────────────────────────────────────────────────── + +SAMPLE_METADATA = { + "headlines": "Critical CVE-2025-9999 found in OpenSSL", + "author": "CISA", + "fullNews": "A critical remote code execution vulnerability was disclosed...", + "newsURL": "https://cisa.gov/known-exploited-vulnerabilities/cve-2025-9999", + "newsImgURL": "https://cisa.gov/img/cve.png", + "newsDate": "Jan 01, 2025", +} + +SAMPLE_ARTICLE = { + "id": 20250101, + "headlines": SAMPLE_METADATA["headlines"], + "author": SAMPLE_METADATA["author"], + "fullNews": SAMPLE_METADATA["fullNews"], + "newsURL": SAMPLE_METADATA["newsURL"], + "newsImgURL": SAMPLE_METADATA["newsImgURL"], + "newsDate": SAMPLE_METADATA["newsDate"], +} + + +def _make_pinecone_index_stub(): + """Return a MagicMock for pinecone.Index with list/fetch/query/upsert.""" + index = MagicMock() + + # index.list() → generator yielding one page of IDs + index.list.return_value = iter([["vec-001"]]) + + # index.fetch() → object with .vectors dict + fetch_result = MagicMock() + vec = MagicMock() + vec.metadata = SAMPLE_METADATA.copy() + fetch_result.vectors = {"vec-001": vec} + index.fetch.return_value = fetch_result + + # index.query() → object with .matches list + query_result = MagicMock() + match = MagicMock() + match.metadata = SAMPLE_METADATA.copy() + match.score = 0.95 + query_result.matches = [match] + index.query.return_value = query_result + + # index.upsert() → no-op + index.upsert.return_value = None + + return index + + +def _make_pinecone_client_stub(index_stub): + """Return a MagicMock for the top-level Pinecone client.""" + pc = MagicMock() + pc.Index.return_value = index_stub + pc.list_indexes.return_value.names.return_value = [] + return pc + + +# ── Session-scoped fixtures (created once per test session) ───────────────── + +@pytest.fixture(scope="session") +def mock_index(): + """A reusable stub for a Pinecone Index.""" + return _make_pinecone_index_stub() + + +@pytest.fixture(scope="session") +def mock_pinecone_client(mock_index): + """A reusable stub for the Pinecone top-level client.""" + return _make_pinecone_client_stub(mock_index) + + +# ── Function-scoped Flask app fixture ──────────────────────────────────────── + +@pytest.fixture() +def flask_app(mock_pinecone_client): + """ + A Flask test application with all external dependencies mocked. + + sentence_transformers and langchain_community are already stubbed via + sys.modules injection at module load time (see top of this file). + Only pinecone.Pinecone needs a runtime patch since it IS installed but + we want to intercept the client constructor to avoid real API calls. + """ + with patch("pinecone.Pinecone", return_value=mock_pinecone_client): + # Import after patch so config.Database.py sees the mock client + from app import app as flask_application + flask_application.config.update({ + "TESTING": True, + "SECRET_KEY": "test-secret", + }) + yield flask_application + + +@pytest.fixture() +def client(flask_app): + """A Flask test client bound to the mocked application.""" + with flask_app.test_client() as c: + yield c + + +# ── Standalone NewsService fixture (no Flask context needed) ───────────────── + +@pytest.fixture() +def news_service_raw(mock_pinecone_client): + """ + A NewsService(model_name=None) instance with Pinecone mocked. + Use this to test service-layer logic in isolation. + """ + with patch("pinecone.Pinecone", return_value=mock_pinecone_client): + from services.NewsService import NewsService + return NewsService(model_name=None) diff --git a/tests/unitTest.py b/tests/unitTest.py index 670c103..9aacdb1 100644 --- a/tests/unitTest.py +++ b/tests/unitTest.py @@ -1,47 +1,114 @@ -import unittest -from flask import Flask -from flask.testing import FlaskClient - -from dotenv import load_dotenv -import os -import sys - -# Get the current file path -current_file_path = os.path.abspath(__file__) - -# Get the grandparent directory -src_directory = os.path.dirname(os.path.dirname(current_file_path)) - -# Add the parent directory to sys.path -sys.path.append(src_directory) - -dotenv_path = os.path.join(src_directory, '.env') -load_dotenv(dotenv_path) - -# Import the Flask application -from app import app - - -class FlaskAppTestCase(unittest.TestCase): - def setUp(self): - # Create a test client - self.app = app.test_client() - - def tearDown(self): - pass - - def test_news(self): - response = self.app.get('/news') - self.assertEqual(response.status_code, 200) - - def test_news_keywords(self): - response = self.app.get('/news_keywords?keywords=firewall') - self.assertEqual(response.status_code, 200) - - def test_invalid_route(self): - response = self.app.get('/xxx') - self.assertEqual(response.status_code, 404) - - -if __name__ == '__main__': - unittest.main() +""" +Flask route integration tests for b0bot. + +Bug fixes vs. the original unitTest.py +─────────────────────────────────────── +1. Routes `/news` and `/news_keywords` no longer exist. + The correct routes introduced in NewsRoutes.py are: + - GET //news + - GET //news_keywords?keywords= + - GET /raw/news ← no LLM; used here to avoid HF token + - GET /raw/news_keywords?keywords= + +2. The 404 handler in NewsRoutes.py (line 73-75) references + `g.news_controller.notFound(error)` but `g.news_controller` is never + set for unknown routes — it only exists inside the LLM routes. + The 404 test is updated to verify the response code only, not + the error body, until the handler is properly fixed. + +3. All Pinecone / SentenceTransformer calls are intercepted by the + shared fixtures in conftest.py — no API keys or network required. +""" +import pytest + + +# ── Fixtures are injected from tests/conftest.py automatically ─────────────── +# `client` provides a Flask test client with all externals mocked. + + +class TestHomeRoute: + """GET / → renders home.html (200).""" + + def test_home_returns_200(self, client): + response = client.get("/") + assert response.status_code == 200 + + def test_home_contains_html(self, client): + response = client.get("/") + assert b" 0 + + +class TestRawNewsRoute: + """ + GET /raw/news → bypasses LLM entirely (NewsController(None)). + This is the safest route to test without HuggingFace credentials. + """ + + def test_raw_news_returns_200(self, client): + response = client.get("/raw/news") + assert response.status_code == 200 + + def test_raw_news_returns_html(self, client): + response = client.get("/raw/news") + # The route renders news.html which should contain some content + assert response.content_type.startswith("text/html") + + +class TestRawNewsKeywordsRoute: + """GET /raw/news_keywords?keywords= → hybrid keyword search, no LLM.""" + + def test_raw_news_keywords_returns_200(self, client): + response = client.get("/raw/news_keywords?keywords=firewall") + assert response.status_code == 200 + + def test_raw_news_keywords_multiple_terms(self, client): + response = client.get("/raw/news_keywords?keywords=ransomware") + assert response.status_code == 200 + + def test_raw_news_keywords_missing_param_raises_error(self, client): + """ + No `keywords` query param → getlist returns [] → user_keywords[0] + raises IndexError. Confirms the existing unhandled edge case. + Tracked as a known bug — proper fix is a 400 guard in the route. + """ + response = client.get("/raw/news_keywords") + # Currently raises 500; document the behaviour, don't silently pass + assert response.status_code in (400, 422, 500) + + +class TestLLMRoutes: + """ + GET / → renders llm.html selecting the given model. + GET //news → LLM-ranked news page. + """ + + def test_set_llm_route_returns_200(self, client): + response = client.get("/mistralai") + assert response.status_code == 200 + + def test_set_llm_route_unknown_model_raises_500(self, client): + """ + Requesting a model name that doesn't exist in llm_config.json + raises ValueError inside NewsController.__init__. Confirms the + current behaviour — a future fix should return a 404/400. + """ + response = client.get("/nonexistent-model-xyz") + assert response.status_code in (500, 404) + + def test_favicon_returns_no_content(self, client): + """ + The set_llm_route has a special case: if llm_name == 'favicon.ico' + it returns 204 No Content instead of trying to load a model. + """ + response = client.get("/favicon.ico") + assert response.status_code == 204 + + +class TestNotFoundRoute: + """Unknown routes → 404. The 404 handler in NewsRoutes has a bug + (references g.news_controller which is not set), so we only assert + the status code, not the response body.""" + + def test_completely_unknown_route_returns_404(self, client): + response = client.get("/this/route/does/not/exist") + assert response.status_code == 404 diff --git a/tests/unitTest_cybernews.py b/tests/unitTest_cybernews.py index 4162958..530b91b 100644 --- a/tests/unitTest_cybernews.py +++ b/tests/unitTest_cybernews.py @@ -1,28 +1,218 @@ """ - Unittest class for CyberNews +Unit tests for the CyberNews aggregator package. + +Bug fixes vs. the original unitTest_cybernews.py +────────────────────────────────────────────────── +1. setUp calls CyberNews() which immediately instantiates Extractor, + RSSExtractor, YouTubeConnector, and NewsAPIConnector — all of which + make network calls or require API keys. All external I/O is now mocked. + +2. test_get_news called self.news.get_news(news) for every valid news type, + which triggers real HTTP scraping. Replaced with mocked Extractor responses. + +3. Added tests for the Sorting utility (pure logic, no mocks needed). +4. Added tests for the Performance utility (pure logic, no mocks needed). """ -import unittest +import pytest +from unittest.mock import MagicMock, patch, PropertyMock + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +SAMPLE_ARTICLE = { + "id": 20250101, + "headlines": "Critical CVE-2025-9999 found in OpenSSL", + "author": "CISA", + "fullNews": "A critical remote code execution vulnerability was disclosed in OpenSSL...", + "newsURL": "https://cisa.gov/known-exploited-vulnerabilities/cve-2025-9999", + "newsImgURL": "https://cisa.gov/img/cve.png", + "newsDate": "January 01, 2025", +} + + +def _make_cybernews_with_mock_extractor(): + """ + Return a CyberNews instance where all external I/O is replaced with mocks. + + Strategy: construct the object first, then swap its private attributes so + no real HTTP requests or API calls ever fire. This avoids the complexity of + patching module-level imports before the class is instantiated. + """ + from cybernews.CyberNews import CyberNews + + # Stub Extractor so its __init__ (httpx.Client) never fires + mock_extractor = MagicMock() + mock_extractor.data_extractor.return_value = [SAMPLE_ARTICLE.copy()] + + mock_rss = MagicMock() + mock_rss.process_feeds.return_value = [] + + mock_yt = MagicMock() + mock_yt.extract.return_value = [] + + mock_napi = MagicMock() + mock_napi.extract.return_value = [] + + # Patch the four constructors so __init__ uses mocks, not real classes + with patch("cybernews.CyberNews.Extractor", return_value=mock_extractor), \ + patch("cybernews.CyberNews.RSSExtractor", return_value=mock_rss), \ + patch("cybernews.CyberNews.YouTubeConnector", return_value=mock_yt), \ + patch("cybernews.CyberNews.NewsAPIConnector", return_value=mock_napi): + news = CyberNews() + + # The object now holds references to our mocks — no context manager needed + return news + + + + +# ───────────────────────────────────────────────────────────────────────────── +# CyberNews tests +# ───────────────────────────────────────────────────────────────────────────── + +class TestCyberNewsInit: + """CyberNews.__init__ loads news_types.json and social_sources.json.""" + + def test_get_news_types_is_not_empty(self): + news = _make_cybernews_with_mock_extractor() + news_types = news.get_news_types + assert isinstance(news_types, list) + assert len(news_types) > 0 + + def test_known_news_types_are_present(self): + news = _make_cybernews_with_mock_extractor() + news_types = news.get_news_types + for expected in ["general", "dataBreach", "cyberAttack", "vulnerability", "malware", "security"]: + assert expected in news_types, f"'{expected}' missing from news_types" + + +class TestCyberNewsGetNews: + """CyberNews.get_news() returns a list of articles for valid types.""" + + @pytest.mark.parametrize("news_type", [ + "general", "dataBreach", "cyberAttack", "vulnerability", "malware", "security", + ]) + def test_get_news_returns_list_for_valid_type(self, news_type): + news = _make_cybernews_with_mock_extractor() + result = news.get_news(news_type) + assert isinstance(result, list), f"get_news('{news_type}') should return a list" + assert len(result) > 0, f"get_news('{news_type}') should return at least one article" + + @pytest.mark.parametrize("news_type", [ + "general", "dataBreach", "cyberAttack", + ]) + def test_each_article_has_required_keys(self, news_type): + news = _make_cybernews_with_mock_extractor() + articles = news.get_news(news_type) + required = {"headlines", "author", "fullNews", "newsURL", "newsDate"} + for article in articles: + missing = required - set(article.keys()) + assert not missing, f"Article missing keys: {missing}" + + @pytest.mark.parametrize("invalid_type", ["", "Invalid", "hacking", "123"]) + def test_get_news_raises_valueerror_for_invalid_type(self, invalid_type): + news = _make_cybernews_with_mock_extractor() + with pytest.raises(ValueError, match=f"'{invalid_type}'"): + news.get_news(invalid_type) + + +# ───────────────────────────────────────────────────────────────────────────── +# Sorting utility tests (pure logic — no mocks needed) +# ───────────────────────────────────────────────────────────────────────────── + +class TestSorting: + """cybernews.sorting.Sorting — date parsing and ordering logic.""" + + @pytest.fixture(autouse=True) + def setup(self): + from cybernews.sorting import Sorting + self.sorting = Sorting() + + def test_ordering_date_american_format(self): + """'January 15, 2025' → integer 20250115.""" + result = self.sorting.ordering_date("January 15, 2025") + assert result == 20250115 + + def test_ordering_date_day_first_format(self): + """'15 January 2025' → integer 20250115.""" + result = self.sorting.ordering_date("15 January 2025") + assert result == 20250115 + + def test_ordering_date_na_returns_1(self): + """'N/A' dates get the lowest sort priority (1).""" + result = self.sorting.ordering_date("N/A") + assert result == 1 + + def test_ordering_date_invalid_returns_1(self): + """Unparseable strings fall back to 1.""" + result = self.sorting.ordering_date("not a date") + assert result == 1 + + def test_ordering_news_sorts_newest_first(self): + articles = [ + {"id": self.sorting.ordering_date("January 01, 2024"), "headlines": "Old"}, + {"id": self.sorting.ordering_date("January 01, 2025"), "headlines": "New"}, + {"id": self.sorting.ordering_date("January 01, 2023"), "headlines": "Older"}, + ] + sorted_articles = self.sorting.ordering_news(articles) + assert sorted_articles[0]["headlines"] == "New" + assert sorted_articles[-1]["headlines"] == "Older" + + def test_ordering_news_assigns_uuid_ids(self): + """After sorting, all IDs are reassigned as large integers (UUID ints).""" + articles = [{"id": 20250101, "headlines": "Test"}] + sorted_articles = self.sorting.ordering_news(articles) + # UUID int is much larger than a date integer + assert sorted_articles[0]["id"] > 10 ** 15 + + +# ───────────────────────────────────────────────────────────────────────────── +# Performance utility tests (pure logic — no mocks needed) +# ───────────────────────────────────────────────────────────────────────────── + +class TestPerformance: + """cybernews.performance.Performance — text cleaning and validation helpers.""" + + @pytest.fixture(autouse=True) + def setup(self): + from cybernews.performance import Performance + self.perf = Performance() + + def test_remove_symbols_strips_non_alphanumeric(self): + assert self.perf.remove_symbols("hello, world!") == "hello world" + + def test_remove_symbols_empty_string(self): + assert self.perf.remove_symbols("") == "" + + def test_remove_symbols_none_returns_empty(self): + assert self.perf.remove_symbols(None) == "" + + def test_valid_url_check_accepts_https(self): + assert self.perf.valid_url_check("https://cisa.gov") is True -from cybernews.CyberNews import CyberNews + def test_valid_url_check_accepts_http(self): + assert self.perf.valid_url_check("http://example.com") is True + def test_valid_url_check_rejects_relative(self): + assert self.perf.valid_url_check("/relative/path") is False -class TestCyberNews(unittest.TestCase): - def setUp(self): - self.news = CyberNews() - self.valid_news = self.news.get_news_types - self.invalid_news = ["", "Invalid"] + def test_valid_url_check_rejects_empty(self): + assert self.perf.valid_url_check("") is False - def test_init(self): - self.assertIsNotNone(self.news.get_news_types) + def test_spam_content_check_detects_known_keywords(self): + assert self.perf.spam_content_check("buy now limited offer") is True - def test_get_news(self): - [self.assertIsNotNone(self.news.get_news(news)) for news in self.valid_news] + def test_spam_content_check_passes_clean_content(self): + assert self.perf.spam_content_check("Critical CVE in OpenSSL patched") is False - def test_get_news_invalid_type(self): - for news in self.invalid_news: - with self.assertRaises(ValueError): - self.news.get_news(news) + def test_is_valid_author_name_rejects_date_strings(self): + """A date like 'Jan 01 2025' should NOT be treated as an author name.""" + assert self.perf.is_valid_author_name("Jan 01 2025") is False + def test_is_valid_author_name_accepts_real_names(self): + assert self.perf.is_valid_author_name("John Doe") is True -if __name__ == "__main__": - unittest.main() + def test_format_author_name_collapses_whitespace(self): + assert self.perf.format_author_name(" John Doe ") == "John Doe"