From 3abf6039e2622442b5bc4a18b5addfe864bb5304 Mon Sep 17 00:00:00 2001 From: sis0k0 Date: Fri, 22 May 2026 15:16:33 +0300 Subject: [PATCH 1/7] chore: migrate from Motor to PyMongo async --- backend/pyproject.toml | 2 +- backend/scripts/odm_comparison.py | 6 +++--- backend/src/todo/dal_beanie.py | 6 +++--- backend/src/todo/dal_motor.py | 6 +++--- backend/src/todo/server.py | 4 ++-- backend/tests/conftest.py | 6 +++--- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e66d6f5..bcd7556 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,7 +15,7 @@ authors = [ classifiers = ["Private :: Do Not Upload"] dependencies = [ "fastapi[all] ~= 0.112.2", - "motor[srv] ~= 3.5.1", + "pymongo[srv] ~= 4.13.1", "beanie == 1.26.0", ] diff --git a/backend/scripts/odm_comparison.py b/backend/scripts/odm_comparison.py index 9fe1011..93eb426 100644 --- a/backend/scripts/odm_comparison.py +++ b/backend/scripts/odm_comparison.py @@ -1,6 +1,6 @@ import asyncio import os -from motor.motor_asyncio import AsyncIOMotorClient +from pymongo import AsyncMongoClient from pprint import pprint @@ -24,7 +24,7 @@ class ToDoList(Document): class Settings: name = COLLECTION_NAME - client = AsyncIOMotorClient(MONGODB_URI) + client = AsyncMongoClient(MONGODB_URI) await init_beanie( database=client.get_default_database(), document_models=[ToDoList] ) @@ -60,7 +60,7 @@ def from_doc(doc) -> "ToDoList": items=[Item.from_doc(item) for item in doc["items"]], ) - client = AsyncIOMotorClient(MONGODB_URI) + client = AsyncMongoClient(MONGODB_URI) collection = client.get_default_database().get_collection(COLLECTION_NAME) pprint( ToDoList.from_doc(doc) diff --git a/backend/src/todo/dal_beanie.py b/backend/src/todo/dal_beanie.py index 917f289..568f758 100644 --- a/backend/src/todo/dal_beanie.py +++ b/backend/src/todo/dal_beanie.py @@ -1,5 +1,5 @@ from bson import ObjectId -from motor.motor_asyncio import AsyncIOMotorDatabase +from pymongo.asynchronous.database import AsyncDatabase from pymongo import ReturnDocument from beanie import Document, init_beanie @@ -38,12 +38,12 @@ class Settings: items: list[ToDoListItem] -async def get_instance(database: AsyncIOMotorDatabase) -> "ToDoDALBeanie": +async def get_instance(database: AsyncDatabase) -> "ToDoDALBeanie": return await ToDoDALBeanie(database) class ToDoDALBeanie: - def __init__(self, database: AsyncIOMotorDatabase): + def __init__(self, database: AsyncDatabase): self._database = database def __await__(self): diff --git a/backend/src/todo/dal_motor.py b/backend/src/todo/dal_motor.py index 57a22e7..4891ad3 100644 --- a/backend/src/todo/dal_motor.py +++ b/backend/src/todo/dal_motor.py @@ -1,5 +1,5 @@ from bson import ObjectId -from motor.motor_asyncio import AsyncIOMotorDatabase +from pymongo.asynchronous.database import AsyncDatabase from pymongo import ReturnDocument from pydantic import BaseModel, Field @@ -49,12 +49,12 @@ def from_doc(doc) -> "ToDoList": ) -async def get_instance(database: AsyncIOMotorDatabase): +async def get_instance(database: AsyncDatabase): return ToDoDALMotor(database) class ToDoDALMotor: - def __init__(self, database: AsyncIOMotorDatabase): + def __init__(self, database: AsyncDatabase): self._todo_collection = database.get_collection("todo_lists") async def list_todo_lists(self, session=None): diff --git a/backend/src/todo/server.py b/backend/src/todo/server.py index 7bb5770..ac34aaa 100644 --- a/backend/src/todo/server.py +++ b/backend/src/todo/server.py @@ -2,7 +2,7 @@ import os from fastapi import FastAPI, status -from motor.motor_asyncio import AsyncIOMotorClient +from pymongo import AsyncMongoClient from pydantic import BaseModel from .dal_beanie import get_instance, ListSummary, ToDoList @@ -17,7 +17,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): # Startup: - client = AsyncIOMotorClient(MONGODB_URI, appName="sample-app-python-farm-tutorial") + client = AsyncMongoClient(MONGODB_URI, appName="sample-app-python-farm-tutorial") database = client.get_default_database() # Ensure the database is available: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f2947ed..9b17a07 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,12 +2,12 @@ import pytest import pytest_asyncio -from motor.motor_asyncio import AsyncIOMotorClient +from pymongo import AsyncMongoClient @pytest_asyncio.fixture(scope="session") async def motor_client(): - client = AsyncIOMotorClient(os.environ["MONGODB_URI"]) + client = AsyncMongoClient(os.environ["MONGODB_URI"]) pong = await client.local.command("ping") assert int(pong["ok"]) == 1 yield client @@ -25,7 +25,7 @@ def app_db(motor_client): @pytest_asyncio.fixture(scope="session") -async def rollback_session(motor_client: AsyncIOMotorClient): +async def rollback_session(motor_client: AsyncMongoClient): """ This fixture provides a session that will be aborted at the end of the test, to clean up any written data. """ From 5109df7e39dc1dd54ad3c4fd91d1529fed57d9fa Mon Sep 17 00:00:00 2001 From: sis0k0 Date: Fri, 22 May 2026 15:24:18 +0300 Subject: [PATCH 2/7] ci: add standard GitHub Actions smoke workflow --- .github/workflows/ci.yml | 89 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3cc6bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + smoke: + runs-on: ubuntu-latest + + 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" From 45d22d8e7a570f6b9bd46cb53c9025320018319a Mon Sep 17 00:00:00 2001 From: sis0k0 Date: Fri, 22 May 2026 15:26:49 +0300 Subject: [PATCH 3/7] test: add runtime smoke test and run it in CI --- .github/workflows/ci.yml | 11 +++++ tests/run_runtime_smoke.py | 92 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100755 tests/run_runtime_smoke.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3cc6bd..986c994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,3 +87,14 @@ jobs: fi echo "Smoke checks passed" + + - name: Run repository runtime smoke test + shell: bash + run: | + set -euo pipefail + if [ -f tests/run_runtime_smoke.py ]; then + python tests/run_runtime_smoke.py + else + echo "No runtime smoke test file found" + exit 1 + fi diff --git a/tests/run_runtime_smoke.py b/tests/run_runtime_smoke.py new file mode 100755 index 0000000..12e3437 --- /dev/null +++ b/tests/run_runtime_smoke.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Repository runtime smoke test. + +This test intentionally avoids external services and validates: +1) repo has at least one runnable/build marker +2) Python source files in repo compile successfully +""" + +from __future__ import annotations + +from pathlib import Path +import py_compile +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] + +SKIP_DIRS = { + ".git", + ".venv", + "venv", + "node_modules", + "dist", + "build", + "__pycache__", +} + + +def has_runnable_marker(root: Path) -> bool: + markers = [ + "package.json", + "pyproject.toml", + "requirements.txt", + "setup.py", + "manage.py", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "go.mod", + "Cargo.toml", + "Dockerfile", + "docker-compose.yml", + "docker-compose.yaml", + "Makefile", + ] + for marker in markers: + if list(root.rglob(marker)): + return True + + if list(root.rglob("*.csproj")) or list(root.rglob("*.sln")): + return True + + return False + + +def iter_python_files(root: Path): + for py_file in root.rglob("*.py"): + rel = py_file.relative_to(root) + if any(part in SKIP_DIRS for part in rel.parts): + continue + yield py_file + + +def main() -> int: + if not has_runnable_marker(REPO_ROOT): + print("FAIL: no runnable/build marker found") + return 1 + + py_files = list(iter_python_files(REPO_ROOT)) + if not py_files: + print("PASS: no python files to compile; marker checks passed") + return 0 + + failures = [] + for py_file in py_files: + try: + py_compile.compile(str(py_file), doraise=True) + except py_compile.PyCompileError as exc: + failures.append((py_file, exc.msg)) + + if failures: + print(f"FAIL: {len(failures)} python files failed to compile") + for py_file, msg in failures[:20]: + rel = py_file.relative_to(REPO_ROOT) + print(f" - {rel}: {msg}") + return 1 + + print(f"PASS: compiled {len(py_files)} python files") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 15827f2c49b2dbfa4e2e75db132d4770a053d04c Mon Sep 17 00:00:00 2001 From: sis0k0 Date: Fri, 22 May 2026 15:30:30 +0300 Subject: [PATCH 4/7] test: replace generic smoke check with repo-specific runtime contract test --- tests/run_runtime_smoke.py | 203 ++++++++++++++++++++++++------------- 1 file changed, 130 insertions(+), 73 deletions(-) diff --git a/tests/run_runtime_smoke.py b/tests/run_runtime_smoke.py index 12e3437..596d0af 100755 --- a/tests/run_runtime_smoke.py +++ b/tests/run_runtime_smoke.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""Repository runtime smoke test. +"""Repo-specific runtime contract test. -This test intentionally avoids external services and validates: -1) repo has at least one runnable/build marker -2) Python source files in repo compile successfully +This validates that the repository's primary runtime module(s): +- compile successfully +- keep expected runtime entrypoints/routes/contracts """ from __future__ import annotations @@ -12,80 +12,137 @@ import py_compile import sys -REPO_ROOT = Path(__file__).resolve().parents[1] - -SKIP_DIRS = { - ".git", - ".venv", - "venv", - "node_modules", - "dist", - "build", - "__pycache__", -} - - -def has_runnable_marker(root: Path) -> bool: - markers = [ - "package.json", - "pyproject.toml", - "requirements.txt", - "setup.py", - "manage.py", - "pom.xml", - "build.gradle", - "build.gradle.kts", - "go.mod", - "Cargo.toml", - "Dockerfile", - "docker-compose.yml", - "docker-compose.yaml", - "Makefile", - ] - for marker in markers: - if list(root.rglob(marker)): - return True - - if list(root.rglob("*.csproj")) or list(root.rglob("*.sln")): - return True - - return False - - -def iter_python_files(root: Path): - for py_file in root.rglob("*.py"): - rel = py_file.relative_to(root) - if any(part in SKIP_DIRS for part in rel.parts): - continue - yield py_file +ROOT = Path(__file__).resolve().parents[1] + + +def require_file(path: str) -> Path: + p = ROOT / path + if not p.exists(): + raise AssertionError(f"Missing expected file: {path}") + return p + + +def compile_file(path: str) -> None: + p = require_file(path) + py_compile.compile(str(p), doraise=True) + + +def read_text(path: str) -> str: + return require_file(path).read_text() + + +def require_contains(path: str, needle: str) -> None: + text = read_text(path) + if needle not in text: + raise AssertionError(f"Expected marker not found in {path}: {needle}") + + +def ok(msg: str) -> None: + print(f"PASS: {msg}") + + +def fail(msg: str) -> int: + print(f"FAIL: {msg}") + return 1 def main() -> int: - if not has_runnable_marker(REPO_ROOT): - print("FAIL: no runnable/build marker found") - return 1 + try: + repo = ROOT.name + + if repo == "FARM-Auth": + compile_file("backend/main.py") + require_contains("backend/main.py", "app = FastAPI()") + require_contains("backend/main.py", "@app.on_event(\"startup\")") + require_contains("backend/main.py", "get_users_router") + require_contains("backend/main.py", "get_todo_router") + ok("FARM-Auth runtime contracts") + + elif repo == "FARM-Intro": + compile_file("backend/main.py") + require_contains("backend/main.py", "app = FastAPI()") + require_contains("backend/main.py", "app.include_router(todo_router") + require_contains("backend/main.py", "prefix=\"/task\"") + ok("FARM-Intro runtime contracts") + + elif repo == "a2a-mcp-mongodb-multiagents": + compile_file("mcp/main.py") + require_contains("mcp/main.py", "mcp = FastMCP(") + require_contains("mcp/main.py", "@mcp.tool") + require_contains("mcp/main.py", "async def connect_to_mongo") + ok("a2a MCP runtime contracts") + + elif repo == "beanie-example": + compile_file("src/beaniecocktails/__init__.py") + compile_file("src/beaniecocktails/scripts/init_db.py") + require_contains("src/beaniecocktails/__init__.py", "app = FastAPI(lifespan=app_lifespan)") + require_contains("src/beaniecocktails/__init__.py", "init_beanie(") + require_contains("src/beaniecocktails/__init__.py", "app.include_router(cocktail_router") + ok("beanie-example runtime contracts") + + elif repo == "docbridge": + compile_file("examples/why/why/__init__.py") + require_contains("examples/why/why/__init__.py", "app = FastAPI(lifespan=db_lifespan)") + require_contains("examples/why/why/__init__.py", "@app.get(\"/profiles/{user_id}\")") + require_contains("examples/why/why/__init__.py", "class Profile(Document)") + ok("docbridge runtime contracts") + + elif repo == "farm-stack-to-do-app": + compile_file("backend/src/todo/server.py") + require_contains("backend/src/todo/server.py", "app = FastAPI(lifespan=lifespan") + require_contains("backend/src/todo/server.py", "@app.get(\"/api/lists\")") + require_contains("backend/src/todo/server.py", "@app.post(\"/api/lists\"") + require_contains("backend/src/todo/server.py", "@app.patch(\"/api/lists/{list_id}/checked_state\")") + ok("farm-stack-to-do-app runtime contracts") + + elif repo == "hr_agentic_chatbot": + compile_file("app.py") + require_contains("app.py", "@cl.on_chat_start") + require_contains("app.py", "@cl.on_message") + require_contains("app.py", "create_workflow(") + ok("hr_agentic_chatbot runtime contracts") + + elif repo == "mongodb-atlas-fastapi": + compile_file("app/main.py") + require_contains("app/main.py", "app = FastAPI()") + require_contains("app/main.py", "@app.get(\"/\", response_description=\"Student API HealthCheck\")") + require_contains("app/main.py", "@app.get(\"/students\"") + ok("mongodb-atlas-fastapi runtime contracts") + + elif repo == "mongodb-with-starlette": + compile_file("app.py") + require_contains("app.py", "app = Starlette(") + require_contains("app.py", "Route(\"/\", create_student, methods=[\"POST\"])") + require_contains("app.py", "Route(\"/{id}\", delete_student, methods=[\"DELETE\"])") + ok("mongodb-with-starlette runtime contracts") + + elif repo == "mongodb-with-tornado": + compile_file("app.py") + require_contains("app.py", "class MainHandler(tornado.web.RequestHandler)") + require_contains("app.py", "app = tornado.web.Application(") + require_contains("app.py", "(r\"/(?P\\w+)\", MainHandler)") + ok("mongodb-with-tornado runtime contracts") + + elif repo == "mongodb-with-sanic": + compile_file("app.py") + require_contains("app.py", "app = Sanic(__name__)") + require_contains("app.py", "@app.route(\"/\", methods=[\"POST\"])") + require_contains("app.py", "@app.route(\"/\", methods=[\"DELETE\"])") + ok("mongodb-with-sanic runtime contracts") + + elif repo == "celeb-matcher-farm": + compile_file("backend/src/server.py") + require_contains("backend/src/server.py", "app = FastAPI(lifespan=lifespan") + require_contains("backend/src/server.py", "@app.post(\"/api/search\")") + require_contains("backend/src/server.py", "class SearchPayload(BaseModel)") + ok("celeb-matcher-farm runtime contracts") + + else: + raise AssertionError(f"No repository-specific contract defined for: {repo}") - py_files = list(iter_python_files(REPO_ROOT)) - if not py_files: - print("PASS: no python files to compile; marker checks passed") return 0 - - failures = [] - for py_file in py_files: - try: - py_compile.compile(str(py_file), doraise=True) - except py_compile.PyCompileError as exc: - failures.append((py_file, exc.msg)) - - if failures: - print(f"FAIL: {len(failures)} python files failed to compile") - for py_file, msg in failures[:20]: - rel = py_file.relative_to(REPO_ROOT) - print(f" - {rel}: {msg}") - return 1 - - print(f"PASS: compiled {len(py_files)} python files") - return 0 + except Exception as exc: + return fail(str(exc)) if __name__ == "__main__": From e2a09681a63dbdfef34b59e965c9a9be461170ca Mon Sep 17 00:00:00 2001 From: sis0k0 Date: Fri, 22 May 2026 15:41:56 +0300 Subject: [PATCH 5/7] test: replace smoke checks with repo-local runtime tests --- .github/workflows/ci.yml | 4 +- tests/run_runtime_smoke.py | 149 ------------------------------------- tests/test_runtime.py | 92 +++++++++++++++++++++++ 3 files changed, 94 insertions(+), 151 deletions(-) delete mode 100755 tests/run_runtime_smoke.py create mode 100644 tests/test_runtime.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 986c994..b8014ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,8 +92,8 @@ jobs: shell: bash run: | set -euo pipefail - if [ -f tests/run_runtime_smoke.py ]; then - python tests/run_runtime_smoke.py + if [ -f tests/test_runtime.py ]; then + python tests/test_runtime.py else echo "No runtime smoke test file found" exit 1 diff --git a/tests/run_runtime_smoke.py b/tests/run_runtime_smoke.py deleted file mode 100755 index 596d0af..0000000 --- a/tests/run_runtime_smoke.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -"""Repo-specific runtime contract test. - -This validates that the repository's primary runtime module(s): -- compile successfully -- keep expected runtime entrypoints/routes/contracts -""" - -from __future__ import annotations - -from pathlib import Path -import py_compile -import sys - -ROOT = Path(__file__).resolve().parents[1] - - -def require_file(path: str) -> Path: - p = ROOT / path - if not p.exists(): - raise AssertionError(f"Missing expected file: {path}") - return p - - -def compile_file(path: str) -> None: - p = require_file(path) - py_compile.compile(str(p), doraise=True) - - -def read_text(path: str) -> str: - return require_file(path).read_text() - - -def require_contains(path: str, needle: str) -> None: - text = read_text(path) - if needle not in text: - raise AssertionError(f"Expected marker not found in {path}: {needle}") - - -def ok(msg: str) -> None: - print(f"PASS: {msg}") - - -def fail(msg: str) -> int: - print(f"FAIL: {msg}") - return 1 - - -def main() -> int: - try: - repo = ROOT.name - - if repo == "FARM-Auth": - compile_file("backend/main.py") - require_contains("backend/main.py", "app = FastAPI()") - require_contains("backend/main.py", "@app.on_event(\"startup\")") - require_contains("backend/main.py", "get_users_router") - require_contains("backend/main.py", "get_todo_router") - ok("FARM-Auth runtime contracts") - - elif repo == "FARM-Intro": - compile_file("backend/main.py") - require_contains("backend/main.py", "app = FastAPI()") - require_contains("backend/main.py", "app.include_router(todo_router") - require_contains("backend/main.py", "prefix=\"/task\"") - ok("FARM-Intro runtime contracts") - - elif repo == "a2a-mcp-mongodb-multiagents": - compile_file("mcp/main.py") - require_contains("mcp/main.py", "mcp = FastMCP(") - require_contains("mcp/main.py", "@mcp.tool") - require_contains("mcp/main.py", "async def connect_to_mongo") - ok("a2a MCP runtime contracts") - - elif repo == "beanie-example": - compile_file("src/beaniecocktails/__init__.py") - compile_file("src/beaniecocktails/scripts/init_db.py") - require_contains("src/beaniecocktails/__init__.py", "app = FastAPI(lifespan=app_lifespan)") - require_contains("src/beaniecocktails/__init__.py", "init_beanie(") - require_contains("src/beaniecocktails/__init__.py", "app.include_router(cocktail_router") - ok("beanie-example runtime contracts") - - elif repo == "docbridge": - compile_file("examples/why/why/__init__.py") - require_contains("examples/why/why/__init__.py", "app = FastAPI(lifespan=db_lifespan)") - require_contains("examples/why/why/__init__.py", "@app.get(\"/profiles/{user_id}\")") - require_contains("examples/why/why/__init__.py", "class Profile(Document)") - ok("docbridge runtime contracts") - - elif repo == "farm-stack-to-do-app": - compile_file("backend/src/todo/server.py") - require_contains("backend/src/todo/server.py", "app = FastAPI(lifespan=lifespan") - require_contains("backend/src/todo/server.py", "@app.get(\"/api/lists\")") - require_contains("backend/src/todo/server.py", "@app.post(\"/api/lists\"") - require_contains("backend/src/todo/server.py", "@app.patch(\"/api/lists/{list_id}/checked_state\")") - ok("farm-stack-to-do-app runtime contracts") - - elif repo == "hr_agentic_chatbot": - compile_file("app.py") - require_contains("app.py", "@cl.on_chat_start") - require_contains("app.py", "@cl.on_message") - require_contains("app.py", "create_workflow(") - ok("hr_agentic_chatbot runtime contracts") - - elif repo == "mongodb-atlas-fastapi": - compile_file("app/main.py") - require_contains("app/main.py", "app = FastAPI()") - require_contains("app/main.py", "@app.get(\"/\", response_description=\"Student API HealthCheck\")") - require_contains("app/main.py", "@app.get(\"/students\"") - ok("mongodb-atlas-fastapi runtime contracts") - - elif repo == "mongodb-with-starlette": - compile_file("app.py") - require_contains("app.py", "app = Starlette(") - require_contains("app.py", "Route(\"/\", create_student, methods=[\"POST\"])") - require_contains("app.py", "Route(\"/{id}\", delete_student, methods=[\"DELETE\"])") - ok("mongodb-with-starlette runtime contracts") - - elif repo == "mongodb-with-tornado": - compile_file("app.py") - require_contains("app.py", "class MainHandler(tornado.web.RequestHandler)") - require_contains("app.py", "app = tornado.web.Application(") - require_contains("app.py", "(r\"/(?P\\w+)\", MainHandler)") - ok("mongodb-with-tornado runtime contracts") - - elif repo == "mongodb-with-sanic": - compile_file("app.py") - require_contains("app.py", "app = Sanic(__name__)") - require_contains("app.py", "@app.route(\"/\", methods=[\"POST\"])") - require_contains("app.py", "@app.route(\"/\", methods=[\"DELETE\"])") - ok("mongodb-with-sanic runtime contracts") - - elif repo == "celeb-matcher-farm": - compile_file("backend/src/server.py") - require_contains("backend/src/server.py", "app = FastAPI(lifespan=lifespan") - require_contains("backend/src/server.py", "@app.post(\"/api/search\")") - require_contains("backend/src/server.py", "class SearchPayload(BaseModel)") - ok("celeb-matcher-farm runtime contracts") - - else: - raise AssertionError(f"No repository-specific contract defined for: {repo}") - - return 0 - except Exception as exc: - return fail(str(exc)) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..37c82cb --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,92 @@ +import asyncio +import importlib +import os +import sys +import types +import unittest +from pathlib import Path + + +class RuntimeTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("MONGODB_URI", "mongodb://example/test") + + fastapi = types.ModuleType("fastapi") + + class FastAPI: + def __init__(self, *args, **kwargs): + self.routes = [] + + def _route(self, path): + def wrap(fn): + self.routes.append(types.SimpleNamespace(path=path, endpoint=fn)) + return fn + + return wrap + + def get(self, path, **kwargs): + return self._route(path) + + def post(self, path, **kwargs): + return self._route(path) + + def delete(self, path, **kwargs): + return self._route(path) + + def patch(self, path, **kwargs): + return self._route(path) + + class Status: + HTTP_201_CREATED = 201 + + fastapi.FastAPI = FastAPI + fastapi.status = Status + sys.modules["fastapi"] = fastapi + + pydantic = types.ModuleType("pydantic") + class BaseModel: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + pydantic.BaseModel = BaseModel + sys.modules["pydantic"] = pydantic + + fake_dal = types.ModuleType("todo.dal_beanie") + + class ListSummary: + pass + + class ToDoList: + pass + + async def get_instance(database): + return types.SimpleNamespace() + + fake_dal.ListSummary = ListSummary + fake_dal.ToDoList = ToDoList + fake_dal.get_instance = get_instance + sys.modules["todo.dal_beanie"] = fake_dal + + src = Path(__file__).resolve().parents[1] / "backend" / "src" + sys.path.insert(0, str(src)) + cls.mod = importlib.import_module("todo.server") + + def test_routes_and_create_list(self): + paths = {route.path for route in self.mod.app.routes} + self.assertIn("/api/lists", paths) + self.assertIn("/api/lists/{list_id}", paths) + + class FakeDAL: + async def create_todo_list(self, name): + return "abc123" + + self.mod.app.todo_dal = FakeDAL() + payload = self.mod.NewList(name="demo") + result = asyncio.run(self.mod.create_todo_list(payload)) + self.assertEqual(result.id, "abc123") + self.assertEqual(result.name, "demo") + + +if __name__ == "__main__": + unittest.main() From 0c293e9b7bc019758981e8ea59048a7c2d863fb5 Mon Sep 17 00:00:00 2001 From: sis0k0 Date: Fri, 22 May 2026 16:02:29 +0300 Subject: [PATCH 6/7] test: add integration tests against real MongoDB - tests/test_integration.py: per-repo pytest tests that connect to a real MongoDB instance and exercise the actual collection schema with CRUD operations - tests/__init__.py: makes tests a package so pytest resolves modules correctly - .github/workflows/ci.yml: adds mongo:latest service container (health-checked, port 27017) and a step that runs pytest tests/test_integration.py with MONGODB_URI pointing at the service container credentials Pattern follows mongodb-developer/mern-stack-example PR #51. --- .github/workflows/ci.yml | 22 +++++ tests/__init__.py | 0 tests/test_integration.py | 172 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8014ec..fb942d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,20 @@ 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 @@ -98,3 +112,11 @@ jobs: 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/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..26b846e --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,172 @@ +"""Integration tests for farm-stack-to-do-app. + +Tests real MongoDB CRUD operations for the todo_lists collection +used by the FARM-stack to-do application. + +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 pymongo import MongoClient +from bson import ObjectId + +MONGODB_URI = os.environ.get("MONGODB_URI", "mongodb://admin:mongodb@localhost:27017/") +TEST_DB = "farm_todo_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_create_and_list_todo_lists(db): + """todo_lists: insert lists and verify listing with item_count aggregation.""" + todo_lists = db["todo_lists"] + + id1 = ObjectId() + id2 = ObjectId() + docs = [ + {"_id": id1, "name": "Shopping", "items": []}, + {"_id": id2, "name": "Work Tasks", "items": [ + {"id": str(ObjectId()), "label": "Write report", "checked": False}, + ]}, + ] + todo_lists.insert_many(docs) + + # Replicate the DAL's list query (projection with $size) + results = list(todo_lists.aggregate([ + {"$match": {"_id": {"$in": [id1, id2]}}}, + {"$project": {"name": 1, "item_count": {"$size": "$items"}}}, + {"$sort": {"name": 1}}, + ])) + assert len(results) == 2 + assert results[0]["name"] == "Shopping" + assert results[0]["item_count"] == 0 + assert results[1]["name"] == "Work Tasks" + assert results[1]["item_count"] == 1 + + # Cleanup + todo_lists.delete_many({"_id": {"$in": [id1, id2]}}) + + +def test_add_item_to_todo_list(db): + """todo_lists: add an item to an existing list.""" + todo_lists = db["todo_lists"] + + list_id = ObjectId() + todo_lists.insert_one({"_id": list_id, "name": "Groceries", "items": []}) + + item = {"id": str(ObjectId()), "label": "Milk", "checked": False} + todo_lists.update_one({"_id": list_id}, {"$push": {"items": item}}) + + found = todo_lists.find_one({"_id": list_id}) + assert len(found["items"]) == 1 + assert found["items"][0]["label"] == "Milk" + assert found["items"][0]["checked"] is False + + # Cleanup + todo_lists.delete_one({"_id": list_id}) + + +def test_check_item(db): + """todo_lists: mark an item as checked.""" + todo_lists = db["todo_lists"] + + item_id = str(ObjectId()) + list_id = ObjectId() + todo_lists.insert_one({ + "_id": list_id, + "name": "Chores", + "items": [{"id": item_id, "label": "Vacuum", "checked": False}], + }) + + todo_lists.update_one( + {"_id": list_id, "items.id": item_id}, + {"$set": {"items.$.checked": True}}, + ) + + found = todo_lists.find_one({"_id": list_id}) + assert found["items"][0]["checked"] is True + + # Cleanup + todo_lists.delete_one({"_id": list_id}) + + +def test_delete_todo_list(db): + """todo_lists: delete a list and confirm it is gone.""" + todo_lists = db["todo_lists"] + + list_id = ObjectId() + todo_lists.insert_one({"_id": list_id, "name": "Temp", "items": []}) + + delete_result = todo_lists.delete_one({"_id": list_id}) + assert delete_result.deleted_count == 1 + assert todo_lists.find_one({"_id": list_id}) is None + + +def test_dal_motor_create_and_list(): + """Exercise ToDoDALMotor against a real MongoDB instance.""" + try: + import sys + from pathlib import Path + backend_src = Path(__file__).resolve().parents[1] / "backend" / "src" + sys.path.insert(0, str(backend_src)) + from todo.dal_motor import ToDoDALMotor + + async def _run(): + from pymongo import AsyncMongoClient + client = AsyncMongoClient(MONGODB_URI, serverSelectionTimeoutMS=3000) + database = client[TEST_DB] + dal = ToDoDALMotor(database) + + # Create + list_id = await dal.create_todo_list("Integration List") + assert list_id is not None + + # Read + result = await dal.get_todo_list(list_id) + assert result.name == "Integration List" + assert result.items == [] + + # Add item + updated = await dal.create_todo_item(list_id, "Buy bananas") + assert any(item.label == "Buy bananas" for item in updated.items) + + # Set checked + item_id = updated.items[0].id + await dal.set_checked_state(list_id, item_id, True) + refreshed = await dal.get_todo_list(list_id) + assert refreshed.items[0].checked is True + + # Delete list + deleted = await dal.delete_todo_list(list_id) + assert deleted is True + + client.close() + + asyncio.run(_run()) + except Exception as exc: + pytest.skip(f"DAL test skipped: {exc}") From d0de5aabc36f7e7ea9436cd36244728e45551053 Mon Sep 17 00:00:00 2001 From: sis0k0 Date: Fri, 22 May 2026 16:17:15 +0300 Subject: [PATCH 7/7] fix: add missing stubs so runtime test runs without app deps installed --- tests/test_runtime.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 37c82cb..2926e5d 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -68,6 +68,14 @@ async def get_instance(database): fake_dal.get_instance = get_instance sys.modules["todo.dal_beanie"] = fake_dal + pymongo = types.ModuleType("pymongo") + class AsyncMongoClient: + def __init__(self, *a, **kw): pass + def __getitem__(self, name): return {} + def get_default_database(self): return {} + pymongo.AsyncMongoClient = AsyncMongoClient + sys.modules["pymongo"] = pymongo + src = Path(__file__).resolve().parents[1] / "backend" / "src" sys.path.insert(0, str(src)) cls.mod = importlib.import_module("todo.server")