Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import FastAPI
import uvicorn
from motor.motor_asyncio import AsyncIOMotorClient
from pymongo import AsyncMongoClient
from config import settings

from apps.todo.routers import router as todo_router
Expand All @@ -10,7 +10,7 @@

@app.on_event("startup")
async def startup_db_client():
app.mongodb_client = AsyncIOMotorClient(settings.DB_URL)
app.mongodb_client = AsyncMongoClient(settings.DB_URL)
app.mongodb = app.mongodb_client[settings.DB_NAME]


Expand Down
2 changes: 1 addition & 1 deletion backend/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ fastapi
pydantic

# Database
motor[srv]
pymongo[srv]

# Development
pip-tools
Expand Down
6 changes: 2 additions & 4 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ markupsafe==2.1.5
# via jinja2
mdurl==0.1.2
# via markdown-it-py
motor[srv]==3.4.0
# via -r requirements.in
mypy-extensions==1.0.0
# via black
orjson==3.10.3
Expand All @@ -82,8 +80,8 @@ pydantic-core==2.18.4
# via pydantic
pygments==2.18.0
# via rich
pymongo[srv]==4.7.3
# via motor
pymongo[srv]==4.13.1
# via -r requirements.in
pyproject-hooks==1.1.0
# via
# build
Expand Down
Empty file added tests/__init__.py
Empty file.
120 changes: 120 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Integration tests for FARM-Intro.

Tests real MongoDB CRUD operations for the task data model
used by the FARM-Intro backend.

Requires a running MongoDB instance. Set MONGODB_URI (default:
mongodb://admin:mongodb@localhost:27017/) or the tests will be skipped.
"""

import os
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_intro_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_task_create_and_find(db):
"""Tasks collection: insert and retrieve a task."""
tasks = db["tasks"]

task_id = str(ObjectId())
task = {
"_id": task_id,
"title": "Learn FastAPI",
"description": "Build a FARM stack app",
"completed": False,
}

result = tasks.insert_one(task)
assert result.inserted_id == task_id

found = tasks.find_one({"_id": task_id})
assert found["title"] == "Learn FastAPI"
assert found["completed"] is False

# Cleanup
tasks.delete_one({"_id": task_id})


def test_task_update(db):
"""Tasks collection: update a task's completion status."""
tasks = db["tasks"]

task_id = str(ObjectId())
tasks.insert_one({"_id": task_id, "title": "Write tests", "completed": False})

tasks.update_one({"_id": task_id}, {"$set": {"completed": True}})
updated = tasks.find_one({"_id": task_id})
assert updated["completed"] is True

# Cleanup
tasks.delete_one({"_id": task_id})


def test_task_list(db):
"""Tasks collection: list all tasks returns correct count."""
tasks = db["tasks"]

ids = [str(ObjectId()) for _ in range(3)]
docs = [
{"_id": ids[0], "title": "Task 1", "completed": False},
{"_id": ids[1], "title": "Task 2", "completed": True},
{"_id": ids[2], "title": "Task 3", "completed": False},
]
tasks.insert_many(docs)

all_tasks = list(tasks.find({}))
assert len(all_tasks) >= 3

incomplete = list(tasks.find({"completed": False}))
assert all(not t["completed"] for t in incomplete)

# Cleanup
tasks.delete_many({"_id": {"$in": ids}})


def test_task_delete(db):
"""Tasks collection: delete a task and confirm it is gone."""
tasks = db["tasks"]

task_id = str(ObjectId())
tasks.insert_one({"_id": task_id, "title": "To be deleted", "completed": False})

delete_result = tasks.delete_one({"_id": task_id})
assert delete_result.deleted_count == 1

assert tasks.find_one({"_id": task_id}) is None


def test_task_not_found(db):
"""Tasks collection: querying a non-existent task returns None."""
tasks = db["tasks"]
assert tasks.find_one({"_id": "nonexistent-id"}) is None
89 changes: 89 additions & 0 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import asyncio
import importlib.util
import sys
import types
import unittest
from pathlib import Path


class RuntimeTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
config = types.ModuleType("config")
config.settings = types.SimpleNamespace(DB_URL="mongodb://example/test", DB_NAME="sample", HOST="127.0.0.1", DEBUG_MODE=False, PORT=8000)
sys.modules["config"] = config

sys.modules.setdefault("uvicorn", types.ModuleType("uvicorn"))

pymongo = types.ModuleType("pymongo")
class _AsyncMongoClient:
def __init__(self, *a, **kw): self.closed = False
def __getitem__(self, name): return {"db_name": name}
def close(self): self.closed = True
pymongo.AsyncMongoClient = _AsyncMongoClient
sys.modules["pymongo"] = pymongo

# Minimal fastapi stub with APIRouter support
fastapi_stub = types.ModuleType("fastapi")
class APIRouter:
def __init__(self):
self._routes = []
def get(self, path, **kwargs):
def wrap(fn):
self._routes.append(types.SimpleNamespace(path=path, endpoint=fn))
return fn
return wrap
class FastAPI:
def __init__(self, *args, **kwargs):
self._routers = []
self.mongodb = None
self.mongodb_client = None
def on_event(self, event):
return lambda fn: fn
def include_router(self, router, *, prefix="", **kwargs):
self._routers.append((router, prefix))
@property
def routes(self):
return [
types.SimpleNamespace(path=prefix + r.path)
for router, prefix in self._routers
for r in router._routes
]
fastapi_stub.FastAPI = FastAPI
fastapi_stub.APIRouter = APIRouter
sys.modules["fastapi"] = fastapi_stub

todo_routers = types.ModuleType("apps.todo.routers")
router = APIRouter()
@router.get("/hello")
async def hello():
return {"ok": True}
todo_routers.router = router
sys.modules["apps.todo.routers"] = todo_routers

target = Path(__file__).resolve().parents[1] / "backend" / "main.py"
spec = importlib.util.spec_from_file_location("farm_intro_main", target)
cls.mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cls.mod)

class FakeClient:
def __init__(self, *args, **kwargs):
self.closed = False
def __getitem__(self, name):
return {"db_name": name}
def close(self):
self.closed = True

cls.mod.AsyncMongoClient = FakeClient

def test_startup_and_router_mount(self):
asyncio.run(self.mod.startup_db_client())
self.assertTrue(hasattr(self.mod.app, "mongodb"))
paths = {route.path for route in self.mod.app.routes}
self.assertIn("/task/hello", paths)
asyncio.run(self.mod.shutdown_db_client())
self.assertTrue(self.mod.app.mongodb_client.closed)


if __name__ == "__main__":
unittest.main()
Loading