Skip to content

Commit ff15ef3

Browse files
authored
Merge pull request #103 from AET-DevOps25/feature/add-genai-tests
Add unit and integration tests for genai module
2 parents dcea2e7 + ff300a5 commit ff15ef3

File tree

11 files changed

+402
-1
lines changed

11 files changed

+402
-1
lines changed

.github/workflows/genai-tests.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: CI/CD Tests in GenAI
2+
3+
on:
4+
push:
5+
branches-ignore:
6+
- main
7+
paths:
8+
- 'genai/**'
9+
- '.github/workflows/genai-tests.yml'
10+
11+
pull_request:
12+
branches-ignore:
13+
- main
14+
paths:
15+
- 'genai/**'
16+
- '.github/workflows/genai-tests.yml'
17+
18+
jobs:
19+
test:
20+
runs-on: ubuntu-latest
21+
# Setup qdrant for integration tests
22+
services:
23+
qdrant:
24+
image: qdrant/qdrant
25+
ports:
26+
- 6333:6333
27+
env:
28+
OPENAI_API_KEY: ${{ secrets.API_OPENAI }}
29+
30+
steps:
31+
- name: Checkout code
32+
uses: actions/checkout@v4
33+
34+
- name: Set up Python
35+
uses: actions/setup-python@v5
36+
with:
37+
python-version: '3.11'
38+
39+
- name: Install dependencies
40+
run: pip install -r requirements.txt
41+
working-directory: genai
42+
43+
- name: Run genai tests
44+
run: |
45+
cd genai
46+
pytest

genai/pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# pytest.ini
2+
[pytest]
3+
pythonpath = .

genai/requirements.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,9 @@ pydantic==2.11.5
2626
uvicorn[standard]==0.34.3
2727

2828
# File Upload
29-
python-multipart==0.0.20
29+
python-multipart==0.0.20
30+
31+
# Testing
32+
pytest==8.4.1
33+
fpdf==1.7.2
34+
pypdf==5.6.0

genai/service/rag_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ def prepare_prompt(system_prompt: str,
3535

3636
def process_raw_messages(raw_messages: List[Dict]) -> List[BaseMessage]:
3737
"""Turns raw messages into BaseMessages, so they can be passed into LLM"""
38+
if not raw_messages:
39+
return []
3840
processed_messages = []
3941
for msg in raw_messages:
4042
role = msg.get("role")
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from fastapi.testclient import TestClient
2+
from unittest.mock import patch, MagicMock
3+
from main import app
4+
5+
client = TestClient(app)
6+
7+
8+
@patch("rag.llm.chat_model.ChatModel.invoke")
9+
@patch("routes.routes.qdrant.client.collection_exists", return_value=False)
10+
def test_generate_endpoint_success(_mock_exists, mock_invoke):
11+
mock_response = MagicMock()
12+
mock_response.content = "This is a test response"
13+
mock_invoke.return_value = mock_response
14+
15+
payload = {
16+
"query": "What can I cook with rice?",
17+
"messages": [
18+
{"role": "USER", "content": "I have rice and eggs"},
19+
{"role": "ASSISTANT", "content": "How about fried rice?"}
20+
]
21+
}
22+
23+
response = client.post("/genai/generate", json=payload)
24+
25+
assert response.status_code == 200
26+
data = response.json()
27+
assert "response" in data
28+
assert data["response"] == "This is a test response"
29+
mock_invoke.assert_called_once()
30+
31+
32+
@patch("rag.llm.chat_model.ChatModel.invoke")
33+
@patch("routes.routes.qdrant.client.collection_exists", return_value=False)
34+
def test_generate_endpoint_empty_messages(_mock_exists, mock_invoke):
35+
36+
mock_response = MagicMock()
37+
mock_response.content = "No prior messages, here's a new idea!"
38+
mock_invoke.return_value = mock_response
39+
40+
payload = {
41+
"query": "Can I cook with lentils?",
42+
"messages": []
43+
}
44+
45+
response = client.post("/genai/generate", json=payload)
46+
47+
assert response.status_code == 200
48+
data = response.json()
49+
assert "response" in data
50+
assert data["response"] == "No prior messages, here's a new idea!"
51+
52+
53+
def test_generate_endpoint_missing_fields():
54+
payload = {
55+
"query": "Can I cook with lentils?"
56+
# "messages" key is missing
57+
}
58+
59+
response = client.post("/genai/generate", json=payload)
60+
61+
assert response.status_code == 400
62+
assert response.json() == {"detail": "Missing 'query' or 'messages'"}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from pathlib import Path
2+
from fpdf import FPDF
3+
from rag.ingestion_pipeline import IngestionPipeline
4+
from vector_database.qdrant_vdb import QdrantVDB
5+
6+
7+
# Helper method to generate a dummy pdf file with real content
8+
def generate_sample_pdf(path: Path, text: str = "Real ingestion test."):
9+
pdf = FPDF()
10+
pdf.add_page()
11+
pdf.set_font("Arial", size=12)
12+
pdf.multi_cell(0, 10, text)
13+
pdf.output(str(path))
14+
15+
16+
def test_real_ingestion_pipeline(tmp_path):
17+
collection_name = "test_collection"
18+
qdrant = QdrantVDB()
19+
# Just for testing purposes
20+
qdrant.host = "http://localhost:6333"
21+
qdrant.client = qdrant.get_vector_database(qdrant.host)
22+
23+
# Ensure collection does not exists before tests
24+
qdrant.delete_collection(collection_name)
25+
26+
# Create a dummy PDF
27+
pdf_path = tmp_path / "sample_test_doc.pdf"
28+
generate_sample_pdf(pdf_path)
29+
filename = pdf_path.name
30+
31+
# Ingestion
32+
vector_store = qdrant.create_and_get_vector_storage(collection_name)
33+
pipeline = IngestionPipeline(vector_store=vector_store)
34+
pipeline.ingest(str(pdf_path), filename)
35+
36+
found = qdrant.collection_contains_file(
37+
qdrant.client,
38+
collection_name,
39+
filename
40+
)
41+
42+
assert found is True
43+
44+
# Clean the vector database
45+
qdrant.delete_collection(collection_name)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import io
2+
from unittest.mock import patch, MagicMock
3+
from fastapi.testclient import TestClient
4+
from main import app
5+
6+
client = TestClient(app)
7+
8+
9+
@patch("routes.routes.qdrant.client.collection_exists", return_value=False)
10+
@patch("routes.routes.qdrant.create_and_get_vector_storage")
11+
@patch("routes.routes.IngestionPipeline")
12+
def test_upload_file_success(
13+
mock_pipeline_class,
14+
_mock_vector_store,
15+
_mock_exists
16+
):
17+
mock_pipeline = MagicMock()
18+
mock_pipeline_class.return_value = mock_pipeline
19+
20+
file_content = b"%PDF-1.4 dummy content"
21+
file = io.BytesIO(file_content)
22+
file.name = "test.pdf"
23+
24+
response = client.post(
25+
"/genai/upload",
26+
files={"file": ("test.pdf", file, "application/pdf")}
27+
)
28+
29+
assert response.status_code == 200
30+
assert response.json() == {"message": "File processed successfully."}
31+
32+
mock_pipeline_class.assert_called_once()
33+
mock_pipeline.ingest.assert_called_once()
34+
35+
36+
def test_upload_file_invalid_type():
37+
file = io.BytesIO(b"just some text")
38+
file.name = "notes.txt"
39+
40+
response = client.post(
41+
"/genai/upload",
42+
files={"file": ("notes.txt", file, "text/plain")}
43+
)
44+
45+
assert response.status_code == 400
46+
assert (response.json()["detail"] ==
47+
"Invalid file type. Only PDF files are allowed.")
48+
49+
50+
@patch("routes.routes.qdrant.client.collection_exists", return_value=True)
51+
@patch("routes.routes.qdrant.collection_contains_file", return_value=True)
52+
def test_upload_file_already_exists(_mock_contains, _mock_exists):
53+
file = io.BytesIO(b"%PDF-1.4")
54+
file.name = "existing.pdf"
55+
56+
response = client.post(
57+
"/genai/upload",
58+
files={"file": ("existing.pdf", file, "application/pdf")}
59+
)
60+
61+
assert response.status_code == 200
62+
assert "already uploaded" in response.json()["message"]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from rag.llm.chat_model import ChatModel
2+
from langchain_core.messages import HumanMessage, AIMessage
3+
from unittest.mock import patch
4+
5+
6+
def test_llm_type_property():
7+
model = ChatModel()
8+
assert model._llm_type == "recipai-custom-model"
9+
10+
11+
def test_get_system_prompt_contains_context():
12+
model = ChatModel()
13+
prompt = model.get_system_prompt()
14+
assert isinstance(prompt, str)
15+
assert "{context}" in prompt
16+
17+
18+
@patch("rag.llm.chat_model.generate_response")
19+
def test_generate_calls_openwebui_and_returns_response(mock_generate):
20+
mock_generate.return_value = "This is a mock response"
21+
model = ChatModel(model_name="mock-model")
22+
23+
messages = [
24+
HumanMessage(content="What can I cook with potatoes?"),
25+
AIMessage(content="You can make mashed potatoes."),
26+
HumanMessage(content="Anything more creative?")
27+
]
28+
29+
result = model._generate(messages)
30+
assert result.generations[0].message.content == "This is a mock response"
31+
mock_generate.assert_called_once()
32+
called_model_name, called_prompt = mock_generate.call_args[0]
33+
assert called_model_name == "mock-model"
34+
assert "User: What can I cook with potatoes?" in called_prompt
35+
assert "Assistant: You can make mashed potatoes." in called_prompt
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from unittest.mock import MagicMock, patch
2+
from uuid import UUID
3+
from rag.ingestion_pipeline import IngestionPipeline
4+
from langchain_core.documents import Document
5+
6+
7+
def test_load_document_returns_documents():
8+
with patch("rag.ingestion_pipeline.PyPDFLoader") as mock_loader:
9+
mock_loader.return_value.load.return_value = [
10+
Document(page_content="Test")
11+
]
12+
pipeline = IngestionPipeline(vector_store=MagicMock())
13+
docs = pipeline.load_document("fake_path.pdf")
14+
assert isinstance(docs, list)
15+
assert isinstance(docs[0], Document)
16+
assert docs[0].page_content == "Test"
17+
18+
19+
def test_chunk_documents_returns_chunks():
20+
pipeline = IngestionPipeline(vector_store=MagicMock())
21+
dummy_doc = Document(
22+
page_content="This is a long text. " * 100,
23+
metadata={}
24+
)
25+
chunks = pipeline.chunk_documents([dummy_doc], filename="sample.pdf")
26+
assert isinstance(chunks, list)
27+
assert all(isinstance(doc, Document) for doc in chunks)
28+
assert all(doc.metadata["source"] == "sample.pdf" for doc in chunks)
29+
30+
31+
def test_store_documents_calls_add_documents_with_uuids():
32+
mock_vector_store = MagicMock()
33+
pipeline = IngestionPipeline(vector_store=mock_vector_store)
34+
docs = [Document(page_content="Chunk", metadata={}) for _ in range(3)]
35+
pipeline.store_documents(docs)
36+
args, kwargs = mock_vector_store.add_documents.call_args
37+
passed_docs = args[0]
38+
passed_ids = kwargs["ids"]
39+
assert len(passed_docs) == 3
40+
assert len(passed_ids) == 3
41+
assert all(UUID(uid) for uid in passed_ids)
42+
43+
44+
def test_ingest_calls_all_steps():
45+
pipeline = IngestionPipeline(vector_store=MagicMock())
46+
47+
with patch.object(pipeline, "load_document") as mock_load, \
48+
patch.object(pipeline, "chunk_documents") as mock_chunk, \
49+
patch.object(pipeline, "store_documents") as mock_store, \
50+
patch("rag.ingestion_pipeline.logger") as mock_logger, \
51+
patch("rag.ingestion_pipeline.file_ingestion_duration.observe"), \
52+
patch("rag.ingestion_pipeline.file_ingested_counter.inc"):
53+
54+
mock_load.return_value = [Document(page_content="Doc")]
55+
mock_chunk.return_value = [Document(page_content="Chunk")]
56+
57+
pipeline.ingest("test.pdf", "testfile.pdf")
58+
59+
mock_load.assert_called_once_with("test.pdf")
60+
mock_chunk.assert_called_once()
61+
mock_store.assert_called_once()
62+
mock_logger.info.assert_any_call(
63+
"Documents are loaded for file %s",
64+
"testfile.pdf"
65+
)

genai/tests/unit/test_prompt.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from service.rag_service import process_raw_messages, prepare_prompt
2+
from langchain_core.messages import HumanMessage, AIMessage
3+
4+
5+
def test_process_raw_messages_creates_correct_types():
6+
raw = [
7+
{"role": "user", "content": "Hi"},
8+
{"role": "assistant", "content": "Hello!"}
9+
]
10+
messages = process_raw_messages(raw)
11+
assert isinstance(messages[0], HumanMessage)
12+
assert isinstance(messages[1], AIMessage)
13+
assert messages[0].content == "Hi"
14+
assert messages[1].content == "Hello!"
15+
16+
17+
def test_process_raw_messages_ignores_unknown_roles():
18+
raw = [
19+
{"role": "user", "content": "Hi"},
20+
{"role": "system", "content": "Should be ignored"}
21+
]
22+
messages = process_raw_messages(raw)
23+
assert len(messages) == 1
24+
assert isinstance(messages[0], HumanMessage)
25+
26+
27+
def test_prepare_prompt_structure():
28+
system_prompt = "You are a helpful assistant. Context: {context}"
29+
query = "What's a good recipe with eggs?"
30+
docs = "Here are some egg-based recipes."
31+
messages = [AIMessage(content="Hi there!")]
32+
33+
prompt = prepare_prompt(system_prompt, query, docs, messages)
34+
35+
assert hasattr(prompt, "to_messages")
36+
final_messages = prompt.to_messages()
37+
assert isinstance(final_messages[-1], HumanMessage)
38+
assert "What's a good recipe with eggs?" in final_messages[-1].content
39+
40+
41+
def test_prepare_prompt_includes_docs_context():
42+
system_prompt = "Use this: {context}"
43+
query = "Tell me something"
44+
docs = "Documented info"
45+
messages = []
46+
47+
prompt = prepare_prompt(system_prompt, query, docs, messages)
48+
rendered = prompt.to_string()
49+
assert "Documented info" in rendered
50+
assert "Tell me something" in rendered

0 commit comments

Comments
 (0)