diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb942d2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,122 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + smoke: + runs-on: ubuntu-latest + + services: + mongodb: + image: mongo:latest + options: >- + --health-cmd mongosh + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 27017:27017 + env: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: mongodb + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Repo smoke checks + shell: bash + run: | + set -euo pipefail + + echo "Validating repository structure and basic runnable signals" + + has_signal=0 + + if find . -maxdepth 4 -type f -name "package.json" | grep -q .; then + has_signal=1 + while IFS= read -r pkg; do + [ -z "$pkg" ] && continue + node -e "const fs=require('fs'); JSON.parse(fs.readFileSync(process.argv[1],'utf8'));" "$pkg" + done < <(find . -maxdepth 4 -type f -name "package.json") + fi + + if find . -maxdepth 4 -type f \( -name "pyproject.toml" -o -name "requirements.txt" -o -name "setup.py" -o -name "manage.py" \) | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f \( -name "app.py" -o -name "main.py" -o -name "wsgi.py" -o -name "asgi.py" \) | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f \( -name "pom.xml" -o -name "build.gradle" -o -name "build.gradle.kts" -o -name "gradlew" \) | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f -name "go.mod" | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f -name "Cargo.toml" | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f \( -name "*.csproj" -o -name "*.sln" \) | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f \( -name "Dockerfile" -o -name "docker-compose.yml" -o -name "docker-compose.yaml" \) | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f -name "Makefile" | grep -q .; then + has_signal=1 + fi + + if [ "$has_signal" -ne 1 ]; then + echo "No runnable/build signals found in repository" + exit 1 + fi + + echo "Running Python syntax smoke check" + python_files="$(find . -type f -name '*.py' -not -path './.git/*' 2>/dev/null || true)" + if [ -n "$python_files" ]; then + while IFS= read -r f; do + [ -z "$f" ] && continue + python -m py_compile "$f" + done <<< "$python_files" + fi + + echo "Smoke checks passed" + + - name: Run repository runtime smoke test + shell: bash + run: | + set -euo pipefail + if [ -f tests/test_runtime.py ]; then + python tests/test_runtime.py + else + echo "No runtime smoke test file found" + exit 1 + fi + + - name: Install integration test dependencies + run: pip install pytest pymongo + + - name: Run integration tests + env: + MONGODB_URI: mongodb://admin:mongodb@localhost:27017/ + run: pytest tests/test_integration.py -v diff --git a/app.py b/app.py index 3730656..d661e74 100644 --- a/app.py +++ b/app.py @@ -10,7 +10,7 @@ from graph import create_workflow, AgentState from mongodb.connect import get_mongo_client from mongodb import checkpointer -from motor.motor_asyncio import AsyncIOMotorClient +from pymongo import AsyncMongoClient from langchain_core.messages import HumanMessage, AIMessage from utilities import sanitize_name from langchain.schema.runnable import Runnable @@ -41,7 +41,7 @@ async def on_chat_start(): workflow = create_workflow(chatbot_agent, tools) - mongo_client = AsyncIOMotorClient(MONGO_URI) + mongo_client = AsyncMongoClient(MONGO_URI) mongodb_checkpointer = checkpointer.MongoDBSaver(mongo_client, DATABASE_NAME, "checkpoints_collection") graph = workflow.compile(checkpointer=mongodb_checkpointer) diff --git a/mongodb/checkpointer.py b/mongodb/checkpointer.py index 66f424d..a0d9980 100644 --- a/mongodb/checkpointer.py +++ b/mongodb/checkpointer.py @@ -14,7 +14,7 @@ SerializerProtocol, ) from langgraph.serde.jsonplus import JsonPlusSerializer -from motor.motor_asyncio import AsyncIOMotorClient +from pymongo import AsyncMongoClient class JsonPlusSerializerCompat(JsonPlusSerializer): def loads(self, data: bytes) -> Any: @@ -25,13 +25,13 @@ def loads(self, data: bytes) -> Any: class MongoDBSaver(AbstractContextManager, BaseCheckpointSaver): serde = JsonPlusSerializerCompat() - client: AsyncIOMotorClient + client: AsyncMongoClient db_name: str collection_name: str def __init__( self, - client: AsyncIOMotorClient, + client: AsyncMongoClient, db_name: str, collection_name: str, *, diff --git a/requirements.txt b/requirements.txt index 8856143..fd83e15 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,7 +53,6 @@ langsmith==0.1.84 Lazify==0.4.0 literalai==0.0.607 marshmallow==3.21.3 -motor==3.5.1 multidict==6.0.5 mypy-extensions==1.0.0 nest-asyncio==1.6.0 @@ -77,7 +76,7 @@ pyasn1_modules==0.4.0 pydantic==2.8.2 pydantic_core==2.20.1 PyJWT==2.8.0 -pymongo==4.8.0 +pymongo==4.13.1 pyparsing==3.1.2 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..04bc355 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,141 @@ +"""Integration tests for hr_agentic_chatbot. + +Tests real MongoDB connectivity and session history storage +used by the HR agentic chatbot. + +Requires a running MongoDB instance. Set MONGODB_URI (default: +mongodb://admin:mongodb@localhost:27017/) or the tests will be skipped. +""" + +import os +import sys +import pytest +from pathlib import Path +from pymongo import MongoClient +from bson import ObjectId + +MONGODB_URI = os.environ.get("MONGODB_URI", "mongodb://admin:mongodb@localhost:27017/") +TEST_DB = "hr_chatbot_integration_test" + + +@pytest.fixture(scope="module") +def db(): + client = MongoClient(MONGODB_URI, serverSelectionTimeoutMS=2000) + try: + client.admin.command("ping") + except Exception: + client.close() + pytest.skip(f"MongoDB not reachable at {MONGODB_URI}") + database = client[TEST_DB] + yield database + client.drop_database(TEST_DB) + client.close() + + +def test_mongodb_ping(): + client = MongoClient(MONGODB_URI, serverSelectionTimeoutMS=2000) + try: + result = client.admin.command("ping") + assert result.get("ok") == 1.0 + except Exception: + pytest.skip(f"MongoDB not reachable at {MONGODB_URI}") + finally: + client.close() + + +def test_get_mongo_client_with_real_uri(): + """get_mongo_client() returns a connected client when given a real URI.""" + try: + connect_mod_path = Path(__file__).resolve().parents[1] / "mongodb" / "connect.py" + + import types + import importlib.util + + # Stub langchain_mongodb if not available + if "langchain_mongodb" not in sys.modules: + stub = types.ModuleType("langchain_mongodb") + chm_stub = types.ModuleType("langchain_mongodb.chat_message_histories") + chm_stub.MongoDBChatMessageHistory = type( + "MongoDBChatMessageHistory", + (), + {"__init__": lambda self, *a, **kw: None}, + ) + stub.chat_message_histories = chm_stub + sys.modules["langchain_mongodb"] = stub + sys.modules["langchain_mongodb.chat_message_histories"] = chm_stub + + if "dotenv" not in sys.modules: + dotenv_stub = types.ModuleType("dotenv") + dotenv_stub.load_dotenv = lambda *a, **kw: None + sys.modules["dotenv"] = dotenv_stub + + spec = importlib.util.spec_from_file_location("hr_connect_int", connect_mod_path) + mod = importlib.util.module_from_spec(spec) + os.environ["MONGO_URI"] = MONGODB_URI + spec.loader.exec_module(mod) + + client = mod.get_mongo_client(MONGODB_URI) + assert client is not None + + result = client.admin.command("ping") + assert result.get("ok") == 1.0 + client.close() + except Exception as exc: + pytest.skip(f"App-level test skipped: {exc}") + + +def test_chat_history_collection_crud(db): + """history collection: store and retrieve chat messages.""" + history = db["history"] + + session_id = f"test_session_{ObjectId()}" + messages = [ + { + "_id": ObjectId(), + "SessionId": session_id, + "History": "Human: Hello\nAI: Hi there!", + }, + { + "_id": ObjectId(), + "SessionId": session_id, + "History": "Human: What is MongoDB?\nAI: A NoSQL database.", + }, + ] + history.insert_many(messages) + + session_messages = list(history.find({"SessionId": session_id})) + assert len(session_messages) == 2 + assert all(m["SessionId"] == session_id for m in session_messages) + + # Cleanup + history.delete_many({"SessionId": session_id}) + + +def test_employee_record_crud(db): + """employee records collection: store and retrieve HR data.""" + employees = db["employees"] + + emp_id = ObjectId() + employee = { + "_id": emp_id, + "name": "Test Employee", + "department": "Engineering", + "role": "Developer", + "salary": 90000, + "start_date": "2023-01-15", + } + + employees.insert_one(employee) + + found = employees.find_one({"_id": emp_id}) + assert found["name"] == "Test Employee" + assert found["department"] == "Engineering" + + # Update + employees.update_one({"_id": emp_id}, {"$set": {"salary": 95000}}) + updated = employees.find_one({"_id": emp_id}) + assert updated["salary"] == 95000 + + # Delete + employees.delete_one({"_id": emp_id}) + assert employees.find_one({"_id": emp_id}) is None diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..7d4ce0d --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,75 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +class RuntimeTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + histories = types.ModuleType("langchain_mongodb.chat_message_histories") + + class MongoDBChatMessageHistory: + def __init__(self, uri, session_id, database_name=None, collection_name=None): + self.uri = uri + self.session_id = session_id + self.database_name = database_name + self.collection_name = collection_name + + histories.MongoDBChatMessageHistory = MongoDBChatMessageHistory + sys.modules["langchain_mongodb"] = types.ModuleType("langchain_mongodb") + sys.modules["langchain_mongodb.chat_message_histories"] = histories + + dotenv = types.ModuleType("dotenv") + dotenv.load_dotenv = lambda *a, **kw: None + sys.modules["dotenv"] = dotenv + + class MongoClient: + def __init__(self, *a, **kw): + self.admin = types.SimpleNamespace(command=lambda c: {"ok": 1}) + def close(self): pass + pymongo_client_mod = types.ModuleType("pymongo.mongo_client") + pymongo_client_mod.MongoClient = MongoClient + sys.modules["pymongo"] = types.ModuleType("pymongo") + sys.modules["pymongo"].MongoClient = MongoClient + sys.modules["pymongo.mongo_client"] = pymongo_client_mod + + target = Path(__file__).resolve().parents[1] / "mongodb" / "connect.py" + spec = importlib.util.spec_from_file_location("hr_connect", target) + cls.mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.mod) + + def test_get_mongo_client_success_and_failure(self): + class GoodAdmin: + @staticmethod + def command(_): + return {"ok": 1} + + class GoodClient: + def __init__(self, *args, **kwargs): + self.admin = GoodAdmin() + + class BadAdmin: + @staticmethod + def command(_): + raise RuntimeError("nope") + + class BadClient: + def __init__(self, *args, **kwargs): + self.admin = BadAdmin() + + self.mod.MongoClient = GoodClient + self.assertIsNotNone(self.mod.get_mongo_client("mongodb://ok")) + + self.mod.MongoClient = BadClient + self.assertIsNone(self.mod.get_mongo_client("mongodb://bad")) + + def test_get_session_history_contract(self): + history = self.mod.get_session_history("s1") + self.assertEqual(history.session_id, "s1") + self.assertEqual(history.collection_name, "history") + + +if __name__ == "__main__": + unittest.main()