Skip to content
Open
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
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
python_files = unitTest*.py test_*.py *_test.py
python_classes = Test*
python_functions = test_*
170 changes: 170 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
161 changes: 114 additions & 47 deletions tests/unitTest.py
Original file line number Diff line number Diff line change
@@ -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 /<llm_name>/news
- GET /<llm_name>/news_keywords?keywords=<term>
- GET /raw/news ← no LLM; used here to avoid HF token
- GET /raw/news_keywords?keywords=<term>

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"<html" in response.data.lower() or len(response.data) > 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=<term> → 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 /<llm_name> → renders llm.html selecting the given model.
GET /<llm_name>/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
Loading