diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..14bef9c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13"] + + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: uv sync --group dev + - name: Run tests + run: uv run pytest tests/ -v diff --git a/crud.py b/crud.py index 67e5356..1558d58 100644 --- a/crud.py +++ b/crud.py @@ -1,14 +1,22 @@ +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from typing import Optional -import models, schemas +import models, schemas def create_user(db: Session, user: schemas.UserCreate): + existing = db.query(models.User).filter(models.User.email == user.email).first() + if existing: + return None db_user = models.User(name=user.name, email=user.email) db.add(db_user) - db.commit() - db.refresh(db_user) - return db_user + try: + db.commit() + db.refresh(db_user) + return db_user + except IntegrityError: + db.rollback() + return None def get_user(db: Session, user_id: int): @@ -20,12 +28,19 @@ def get_users(db: Session, skip: int = 0, limit: int = 10): -def create_task(db:Session, task: schemas.TaskCreate): +def create_task(db: Session, task: schemas.TaskCreate): + owner = db.query(models.User).filter(models.User.id == task.owner_id).first() + if not owner: + return None db_task = models.Task(**task.model_dump()) db.add(db_task) - db.commit() - db.refresh(db_task) - return db_task + try: + db.commit() + db.refresh(db_task) + return db_task + except IntegrityError: + db.rollback() + return None def get_task(db:Session, task_id: int): return db.query(models.Task).filter(models.Task.id == task_id).first() diff --git a/main.py b/main.py index 8de385d..5beedd2 100644 --- a/main.py +++ b/main.py @@ -13,14 +13,12 @@ version="1.0.0" ) -origins = [ - "http://localhost:3000", # React dev server - "http://localhost:5173", # Vite dev server -] - app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[ + "http://localhost:3000", # React dev server + "http://localhost:5173", # Vite dev server + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/models.py b/models.py index d9182d0..3667a20 100644 --- a/models.py +++ b/models.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, Boolean, ForeignKey +from datetime import date +from sqlalchemy import Column, Integer, String, Boolean, Date, ForeignKey from sqlalchemy.orm import relationship from database import Base @@ -20,6 +21,7 @@ class Task(Base): description = Column(String, nullable=True) completed = Column(Boolean, default=False) priority = Column(String, default="medium") + due_date = Column(Date, nullable=True) owner_id = Column(Integer, ForeignKey("users.id")) owner = relationship("User", back_populates="tasks") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2955948..de4ab9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "task-manager" version = "0.1.0" -description = "Add your description here" +description = "A simple task manager built with FastAPI and SQLite" readme = "README.md" requires-python = ">=3.13" dependencies = [ @@ -9,3 +9,18 @@ dependencies = [ "sqlalchemy>=2.0.51", "uvicorn>=0.49.0", ] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "httpx>=0.27", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=9.1.1", +] diff --git a/routers/tasks.py b/routers/tasks.py index 921488e..bc391f8 100644 --- a/routers/tasks.py +++ b/routers/tasks.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from typing import List, Optional import crud, schemas @@ -6,13 +6,16 @@ router = APIRouter(prefix="/tasks", tags=["Tasks"]) -@router.post("/", response_model=schemas.TaskResponse) +@router.post("/", response_model=schemas.TaskResponse, status_code=201) def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)): - return crud.create_task(db, task) + result = crud.create_task(db, task) + if not result: + raise HTTPException(status_code=400, detail="Invalid owner_id or conflicting data") + return result @router.get("/", response_model=List[schemas.TaskResponse]) -def get_tasks(skip: int = 0, limit: int = 10, completed: Optional[bool] = None, db: Session = Depends(get_db)): +def get_tasks(skip: int = Query(0, ge=0), limit: int = Query(10, ge=1, le=1000), completed: Optional[bool] = None, db: Session = Depends(get_db)): return crud.get_tasks(db, skip=skip, limit=limit, completed=completed) diff --git a/routers/users.py b/routers/users.py index c449950..a17cf56 100644 --- a/routers/users.py +++ b/routers/users.py @@ -1,18 +1,21 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from typing import List -import crud, schemas +import crud, schemas from database import get_db router = APIRouter(prefix="/users", tags=["Users"]) -@router.post("/", response_model=schemas.UserResponse) +@router.post("/", response_model=schemas.UserResponse, status_code=201) def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): - return crud.create_user(db, user) + result = crud.create_user(db, user) + if not result: + raise HTTPException(status_code=409, detail="Email already registered") + return result @router.get("/", response_model=List[schemas.UserResponse]) -def get_users(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): +def get_users(skip: int = Query(0, ge=0), limit: int = Query(10, ge=1, le=1000), db: Session = Depends(get_db)): return crud.get_users(db, skip=skip, limit=limit) @router.get("/{user_id}", response_model=schemas.UserResponse) @@ -22,12 +25,13 @@ def get_user(user_id: int, db: Session = Depends(get_db)): raise HTTPException(status_code=404, detail="User not found") return user -@router.put("/{user_id}", response_model= schemas.UserResponse) +@router.put("/{user_id}", response_model=schemas.UserResponse) def update_user(user_id: int, data: schemas.UserUpdate, db: Session = Depends(get_db)): user = crud.update_user(db, user_id, data) if not user: raise HTTPException(status_code=404, detail="User not found") - + return user + @router.delete("/{user_id}") def delete_user(user_id: int, db: Session = Depends(get_db)): user = crud.delete_user(db, user_id) diff --git a/schemas.py b/schemas.py index de48d02..bcdb1b2 100644 --- a/schemas.py +++ b/schemas.py @@ -1,3 +1,4 @@ +from datetime import date from pydantic import BaseModel from typing import Optional, List @@ -8,17 +9,20 @@ class TaskBase(BaseModel): class TaskCreate(TaskBase): owner_id: int priority: str = "medium" + due_date: Optional[date] = None class TaskUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None completed: Optional[bool] = None priority: Optional[str] = None + due_date: Optional[date] = None class TaskResponse(TaskBase): id: int completed: bool priority: str + due_date: Optional[date] = None owner_id: int class Config: diff --git a/tests/test_task_manager.py b/tests/test_task_manager.py new file mode 100644 index 0000000..f5d6552 --- /dev/null +++ b/tests/test_task_manager.py @@ -0,0 +1,197 @@ +import os +import tempfile +import unittest + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import database +import main +import models + + +class TaskManagerTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + cls.temp_db.close() + + database.engine = create_engine( + f"sqlite:///{cls.temp_db.name}", + connect_args={"check_same_thread": False}, + ) + database.SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=database.engine, + ) + models.Base.metadata.create_all(bind=database.engine) + cls.client = TestClient(main.app) + + @classmethod + def tearDownClass(cls): + if hasattr(cls, "client"): + cls.client.close() + if hasattr(database, "SessionLocal"): + database.SessionLocal.close_all() + if hasattr(database, "engine"): + database.engine.dispose() + if os.path.exists(cls.temp_db.name): + try: + os.remove(cls.temp_db.name) + except PermissionError: + pass + + def setUp(self): + db = database.SessionLocal() + try: + db.query(models.Task).delete() + db.query(models.User).delete() + db.commit() + finally: + db.close() + + +class TestPagination(TaskManagerTests): + def setUp(self): + super().setUp() + db = database.SessionLocal() + try: + for index in range(12): + user = models.User(name=f"User {index}", email=f"user{index}@example.com") + db.add(user) + db.commit() + + users = db.query(models.User).all() + for index, user in enumerate(users): + db.add(models.Task(title=f"Task {index}", description="desc", owner_id=user.id)) + db.commit() + finally: + db.close() + + def test_tasks_pagination(self): + response = self.client.get("/tasks/") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 10) + + paged_response = self.client.get("/tasks/", params={"skip": 5, "limit": 5}) + self.assertEqual(paged_response.status_code, 200) + data = paged_response.json() + self.assertEqual(len(data), 5) + self.assertEqual(data[0]["title"], "Task 5") + + def test_users_pagination(self): + response = self.client.get("/users/") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 10) + + paged_response = self.client.get("/users/", params={"skip": 5, "limit": 5}) + self.assertEqual(paged_response.status_code, 200) + data = paged_response.json() + self.assertEqual(len(data), 5) + self.assertEqual(data[0]["email"], "user5@example.com") + + +class TestCRUD(TaskManagerTests): + def test_create_user(self): + response = self.client.post("/users/", json={"name": "Alice", "email": "alice@example.com"}) + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertEqual(data["name"], "Alice") + self.assertEqual(data["email"], "alice@example.com") + self.assertIn("id", data) + + def test_create_duplicate_email(self): + self.client.post("/users/", json={"name": "Alice", "email": "dup@example.com"}) + response = self.client.post("/users/", json={"name": "Bob", "email": "dup@example.com"}) + self.assertEqual(response.status_code, 409) + + def test_get_user_by_id(self): + created = self.client.post("/users/", json={"name": "Alice", "email": "alice@example.com"}).json() + response = self.client.get(f"/users/{created['id']}") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["name"], "Alice") + + def test_get_user_not_found(self): + response = self.client.get("/users/999") + self.assertEqual(response.status_code, 404) + + def test_update_user(self): + created = self.client.post("/users/", json={"name": "Alice", "email": "alice@example.com"}).json() + response = self.client.put(f"/users/{created['id']}", json={"name": "Alicia"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["name"], "Alicia") + + def test_update_user_not_found(self): + response = self.client.put("/users/999", json={"name": "Ghost"}) + self.assertEqual(response.status_code, 404) + + def test_delete_user(self): + created = self.client.post("/users/", json={"name": "Alice", "email": "alice@example.com"}).json() + response = self.client.delete(f"/users/{created['id']}") + self.assertEqual(response.status_code, 200) + get_response = self.client.get(f"/users/{created['id']}") + self.assertEqual(get_response.status_code, 404) + + def test_delete_user_not_found(self): + response = self.client.delete("/users/999") + self.assertEqual(response.status_code, 404) + + def test_create_task(self): + user = self.client.post("/users/", json={"name": "Alice", "email": "alice@example.com"}).json() + response = self.client.post("/tasks/", json={ + "title": "Test Task", + "description": "A task", + "owner_id": user["id"], + }) + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertEqual(data["title"], "Test Task") + self.assertEqual(data["owner_id"], user["id"]) + + def test_create_task_invalid_owner(self): + response = self.client.post("/tasks/", json={ + "title": "Orphan Task", + "owner_id": 999, + }) + self.assertEqual(response.status_code, 400) + + def test_get_task_by_id(self): + user = self.client.post("/users/", json={"name": "Alice", "email": "alice@example.com"}).json() + task = self.client.post("/tasks/", json={"title": "Task", "owner_id": user["id"]}).json() + response = self.client.get(f"/tasks/{task['id']}") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["title"], "Task") + + def test_get_task_not_found(self): + response = self.client.get("/tasks/999") + self.assertEqual(response.status_code, 404) + + def test_update_task(self): + user = self.client.post("/users/", json={"name": "Alice", "email": "alice@example.com"}).json() + task = self.client.post("/tasks/", json={"title": "Old", "owner_id": user["id"]}).json() + response = self.client.put(f"/tasks/{task['id']}", json={"title": "New", "completed": True}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["title"], "New") + self.assertTrue(response.json()["completed"]) + + def test_update_task_not_found(self): + response = self.client.put("/tasks/999", json={"title": "Ghost"}) + self.assertEqual(response.status_code, 404) + + def test_delete_task(self): + user = self.client.post("/users/", json={"name": "Alice", "email": "alice@example.com"}).json() + task = self.client.post("/tasks/", json={"title": "ToDelete", "owner_id": user["id"]}).json() + response = self.client.delete(f"/tasks/{task['id']}") + self.assertEqual(response.status_code, 200) + get_response = self.client.get(f"/tasks/{task['id']}") + self.assertEqual(get_response.status_code, 404) + + def test_delete_task_not_found(self): + response = self.client.delete("/tasks/999") + self.assertEqual(response.status_code, 404) + + +if __name__ == "__main__": + unittest.main()