diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7db0b58 --- /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 pytest-asyncio + + - name: Run integration tests + env: + MONGODB_URI: mongodb://admin:mongodb@localhost:27017/ + run: pytest tests/test_integration.py -v diff --git a/examples/why/requirements.in b/examples/why/requirements.in index 97a0f0a..8398c2e 100644 --- a/examples/why/requirements.in +++ b/examples/why/requirements.in @@ -1,5 +1,5 @@ fastapi -motor[srv] +pymongo[srv] uvicorn jinja2 beanie \ No newline at end of file diff --git a/examples/why/requirements.txt b/examples/why/requirements.txt index c6053d3..4425973 100644 --- a/examples/why/requirements.txt +++ b/examples/why/requirements.txt @@ -28,10 +28,6 @@ lazy-model==0.2.0 # via beanie markupsafe==2.1.5 # via jinja2 -motor[srv]==3.4.0 - # via - # -r requirements.in - # beanie pydantic==2.7.1 # via # beanie @@ -39,8 +35,10 @@ pydantic==2.7.1 # lazy-model pydantic-core==2.18.2 # via pydantic -pymongo[srv]==4.6.3 - # via motor +pymongo[srv]==4.13.1 + # via + # -r requirements.in + # beanie sniffio==1.3.1 # via anyio starlette==0.37.2 diff --git a/examples/why/why/__init__.py b/examples/why/why/__init__.py index d8ab124..be1fd41 100644 --- a/examples/why/why/__init__.py +++ b/examples/why/why/__init__.py @@ -4,7 +4,7 @@ from fastapi import FastAPI -from motor.motor_asyncio import AsyncIOMotorClient +from pymongo import AsyncMongoClient # from docbridge import Document, Field, SequenceField from beanie import Document, init_beanie from pydantic import BaseModel, Field @@ -16,8 +16,8 @@ @asynccontextmanager async def db_lifespan(app: FastAPI): # Startup - app.mongodb_client = motor = AsyncIOMotorClient(CONNECTION_STRING) - app.database = db = motor.get_database("why") + app.mongodb_client = mongo_client = AsyncMongoClient(CONNECTION_STRING) + app.database = db = mongo_client.get_database("why") ping_response = await db.command("ping") if int(ping_response["ok"]) != 1: raise Exception("Problem connecting to database cluster.") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index fdb238b..49578d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import os import pytest_asyncio -from motor.motor_asyncio import AsyncIOMotorClient as MotorClient +from pymongo import AsyncMongoClient as MotorClient @pytest_asyncio.fixture(scope="session") diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..55e5011 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,175 @@ +"""Integration tests for docbridge. + +Tests real MongoDB CRUD operations for the profiles collection used by the +docbridge FastAPI + Beanie example. + +Requires a running MongoDB instance. Set MONGODB_URI (default: +mongodb://admin:mongodb@localhost:27017/) or the tests will be skipped. +""" + +import os +import asyncio +import pytest +from datetime import datetime +from pymongo import MongoClient +from bson import ObjectId + +MONGODB_URI = os.environ.get("MONGODB_URI", "mongodb://admin:mongodb@localhost:27017/") +TEST_DB = "docbridge_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_profile_crud(db): + """profiles collection: insert, find, update, delete a profile.""" + profiles = db["profiles"] + + profile_id = ObjectId() + profile = { + "_id": profile_id, + "user_id": "u_001", + "user_name": "jdoe", + "full_name": "Jane Doe", + "birth_date": datetime(1990, 5, 15), + "email": "jane.doe@example.com", + "followers": [{"user_id": "u_002"}, {"user_id": "u_003"}], + } + + # Create + result = profiles.insert_one(profile) + assert result.inserted_id == profile_id + + # Read by user_id + found = profiles.find_one({"user_id": "u_001"}) + assert found["user_name"] == "jdoe" + assert found["full_name"] == "Jane Doe" + assert len(found["followers"]) == 2 + + # Update + profiles.update_one( + {"_id": profile_id}, + {"$push": {"followers": {"user_id": "u_004"}}} + ) + updated = profiles.find_one({"_id": profile_id}) + assert len(updated["followers"]) == 3 + + # Delete + delete_result = profiles.delete_one({"_id": profile_id}) + assert delete_result.deleted_count == 1 + assert profiles.find_one({"_id": profile_id}) is None + + +def test_profile_not_found(db): + """profiles collection: querying a non-existent user_id returns None.""" + profiles = db["profiles"] + assert profiles.find_one({"user_id": "nonexistent_user"}) is None + + +def test_profile_follower_query(db): + """profiles collection: query profiles that have a specific follower.""" + profiles = db["profiles"] + + ids = [ObjectId(), ObjectId()] + docs = [ + { + "_id": ids[0], + "user_id": "u_010", + "user_name": "alice", + "full_name": "Alice Smith", + "birth_date": datetime(1992, 3, 10), + "email": "alice@example.com", + "followers": [{"user_id": "u_999"}], + }, + { + "_id": ids[1], + "user_id": "u_011", + "user_name": "bob", + "full_name": "Bob Jones", + "birth_date": datetime(1988, 7, 20), + "email": "bob@example.com", + "followers": [], + }, + ] + profiles.insert_many(docs) + + followed_by_999 = list( + profiles.find({"followers.user_id": "u_999", "_id": {"$in": ids}}) + ) + assert len(followed_by_999) == 1 + assert followed_by_999[0]["user_name"] == "alice" + + # Cleanup + profiles.delete_many({"_id": {"$in": ids}}) + + +def test_read_item_via_beanie(db): + """Run the read_item endpoint logic against real MongoDB via Beanie.""" + try: + from beanie import init_beanie, Document + from pymongo import AsyncMongoClient + from pydantic import BaseModel, Field + + class Follower(BaseModel): + user_id: str + + class ProfileDoc(Document): + user_id: str + user_name: str + full_name: str + birth_date: datetime + email: str + followers: list + + class Settings: + name = "profiles" + + async def _run(): + client = AsyncMongoClient(MONGODB_URI, serverSelectionTimeoutMS=3000) + database = client[TEST_DB] + await init_beanie(database=database, document_models=[ProfileDoc]) + + # Insert via Beanie + p = ProfileDoc( + user_id="u_beanie_test", + user_name="beanie_user", + full_name="Beanie Tester", + birth_date=datetime(1995, 1, 1), + email="beanie@test.com", + followers=[], + ) + await p.insert() + + # Query via Beanie (mimics read_item endpoint) + found = await ProfileDoc.find_one({"user_id": "u_beanie_test"}) + assert found is not None + assert found.user_name == "beanie_user" + + await found.delete() + client.close() + + asyncio.run(_run()) + except ImportError: + pytest.skip("beanie not installed") diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..f341bdc --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,74 @@ +import asyncio +import importlib.util +import os +import sys +import types +import unittest +from pathlib import Path + + +class RuntimeTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("MDB_URI", "mongodb://example/test") + + fastapi = types.ModuleType("fastapi") + + class FastAPI: + def __init__(self, *args, **kwargs): + self.routes = [] + + def get(self, path, **kwargs): + def wrap(fn): + self.routes.append(types.SimpleNamespace(path=path, endpoint=fn)) + return fn + + return wrap + + fastapi.FastAPI = FastAPI + sys.modules["fastapi"] = fastapi + + beanie = types.ModuleType("beanie") + class Document: + pass + beanie.Document = Document + async def init_beanie(*args, **kwargs): + return None + beanie.init_beanie = init_beanie + sys.modules["beanie"] = beanie + + pydantic = types.ModuleType("pydantic") + class BaseModel: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + pydantic.BaseModel = BaseModel + pydantic.Field = lambda default=None, **kw: default + sys.modules["pydantic"] = pydantic + + pymongo = types.ModuleType("pymongo") + class AsyncMongoClient: + def __init__(self, *a, **kw): pass + def __getitem__(self, name): return {} + def get_database(self, name=None): return {} + pymongo.AsyncMongoClient = AsyncMongoClient + sys.modules["pymongo"] = pymongo + + target = Path(__file__).resolve().parents[1] / "examples" / "why" / "why" / "__init__.py" + spec = importlib.util.spec_from_file_location("docbridge_why", target) + cls.mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.mod) + + def test_read_item_calls_profile_query(self): + class FakeProfile: + @staticmethod + async def find_one(query): + return {"user_id": query["user_id"]} + + self.mod.Profile = FakeProfile + result = asyncio.run(self.mod.read_item("u1")) + self.assertEqual(result["user_id"], "u1") + + +if __name__ == "__main__": + unittest.main()