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/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. """ 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}") diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..2926e5d --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,100 @@ +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 + + 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") + + 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()