diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1d5e5ef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +venv +__pycache__ +.pytest_cache +tests +htmlcov +.coverage +.env +docs +*.pyc +!tests/ \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ff352ef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: TaskTracker CI + +on: + push: + branches: + - main + - develop + - dev + - feature/** + pull_request: + branches: + - main + - develop + - dev + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: tasktracker + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/tasktracker + APP_PORT: 8000 + LOG_LEVEL: INFO + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run tests + run: python -m pytest -v + + - name: Lint with Ruff + run: ruff check . + + - name: Run tests with coverage gate + run: python -m pytest --cov=app --cov-report=term-missing --cov-fail-under=80 + + + publish: + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/tasktracker:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea79268 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12.10-slim-bookworm AS builder + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +COPY app ./app + +FROM python:3.12.10-slim-bookworm + +RUN useradd -m app + +WORKDIR /app + +COPY --from=builder /install /usr/local +COPY app ./app + +USER app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..cf76478 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,15 @@ +FROM python:3.12.10-slim-bookworm + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY tests ./tests +COPY pytest.ini* ./ +ENV DATABASE_URL=sqlite:///./test.db \ + APP_PORT=8000 \ + LOG_LEVEL=INFO + +RUN python -m pytest -v \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2a1c5cb --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: run test coverage lint format install + +install: + pip install -r requirements.txt + +run: + uvicorn app.main:app --reload + +test: + pytest -v + +coverage: + pytest --cov=app --cov-report=term-missing + +lint: + flake8 app tests + +format: + black . + isort . \ No newline at end of file diff --git a/README.md b/README.md index ca3e49f..3c6de83 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,63 @@ -# TaskTracker -Production-ready TaskTracker backend built with FastAPI, PostgreSQL, Docker, Kubernetes, Terraform and CI/CD practices. Includes migrations, structured JSON logging, health checks, automated testing and GitHub workflow automation. +# TaskTracker Backend + +TaskTracker is a production-ready backend API built with FastAPI for managing users and tasks, featuring automated testing, containerization, and CI/CD. + +--- + +## Tech Stack + +* FastAPI +* PostgreSQL +* SQLAlchemy +* Alembic +* Pytest & pytest-cov +* Docker & Docker Compose +* GitHub Actions + +--- + +## Features + +### Users + +* Create and retrieve users +* Email uniqueness validation + +### Tasks + +* Create, retrieve, update, and delete tasks +* Filter tasks by owner and status +* Input and ownership validation + +### Health Endpoints + +* `/healthz` +* `/readyz` + +--- + +## DevOps Highlights + +* Multi-stage Docker image running as a non-root user +* Docker Compose setup with PostgreSQL health checks +* Automated testing and coverage reporting via GitHub Actions +* Automatic Docker Hub image publishing after successful CI + +--- + +## Project Structure + +```text +app/ +├── api/ +├── core/ +├── db/ +├── middleware/ +├── models/ +├── schemas/ +└── services/ + +tests/ +docs/ +.github/workflows/ +``` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..807ded2 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..8b817c8 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,101 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +from alembic import context +from dotenv import load_dotenv +import os + +config = context.config + +load_dotenv() + +database_url = os.getenv("DATABASE_URL") + +if not database_url: + raise ValueError( + "DATABASE_URL environment variable is required" + ) + +config.set_main_option( + "sqlalchemy.url", + database_url +) +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +# target_metadata = None +# replacing with the actual metadata from models +from app.db.base import Base + +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/c2993e924a46_create_users_and_tasks_tables.py b/alembic/versions/c2993e924a46_create_users_and_tasks_tables.py new file mode 100644 index 0000000..ccf4fa1 --- /dev/null +++ b/alembic/versions/c2993e924a46_create_users_and_tasks_tables.py @@ -0,0 +1,60 @@ +"""create users and tasks tables + +Revision ID: c2993e924a46 +Revises: +Create Date: 2026-06-19 13:10:33.579909 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c2993e924a46' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('tasks', 'title', + existing_type=sa.VARCHAR(length=200), + nullable=False) + op.alter_column('tasks', 'status', + existing_type=sa.VARCHAR(length=50), + nullable=False) + op.alter_column('tasks', 'owner_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('users', 'name', + existing_type=sa.VARCHAR(length=100), + nullable=False) + op.alter_column('users', 'email', + existing_type=sa.VARCHAR(length=100), + nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'email', + existing_type=sa.VARCHAR(length=100), + nullable=True) + op.alter_column('users', 'name', + existing_type=sa.VARCHAR(length=100), + nullable=True) + op.alter_column('tasks', 'owner_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('tasks', 'status', + existing_type=sa.VARCHAR(length=50), + nullable=True) + op.alter_column('tasks', 'title', + existing_type=sa.VARCHAR(length=200), + nullable=True) + # ### end Alembic commands ### diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..5d2787b --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["Health"]) + + +@router.get("/healthz") +def health_check(): + return { + "status": "ok" + } \ No newline at end of file diff --git a/app/api/ready.py b/app/api/ready.py new file mode 100644 index 0000000..8808966 --- /dev/null +++ b/app/api/ready.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.db.session import get_db + +router = APIRouter(tags=["Readiness"]) + + +@router.get("/readyz") +def readiness_check(db: Session = Depends(get_db)): + try: + db.execute(text("SELECT 1")) + return { + "status": "ready" + } + + except Exception: + raise HTTPException( + status_code=503, + detail="Database not ready" + ) \ No newline at end of file diff --git a/app/api/tasks.py b/app/api/tasks.py new file mode 100644 index 0000000..5cea72b --- /dev/null +++ b/app/api/tasks.py @@ -0,0 +1,222 @@ +import logging + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Query, + Request, + status +) +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.task import Task +from app.models.user import User +from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/tasks", + tags=["Tasks"] +) + +VALID_STATUS = ["pending", "in_progress", "completed"] + + +# ---------------- CREATE TASK ---------------- +@router.post( + "", + status_code=status.HTTP_201_CREATED, + response_model=TaskResponse +) +def create_task( + request: Request, + payload: TaskCreate, + db: Session = Depends(get_db) +): + + request_id = getattr(request.state, "request_id", "N/A") + + owner = db.query(User).filter(User.id == payload.owner_id).first() + + if not owner: + logger.warning( + "task creation failed - owner not found", + extra={"request_id": request_id} + ) + raise HTTPException( + status_code=404, + detail="Owner not found" + ) + + task = Task( + title=payload.title, + description=payload.description, + owner_id=payload.owner_id + ) + + db.add(task) + db.commit() + db.refresh(task) + + logger.info( + f"task created id={task.id}", + extra={"request_id": request_id} + ) + + return task + + +# ---------------- GET TASKS ---------------- +@router.get( + "", + response_model=list[TaskResponse] +) +def get_tasks( + request: Request, + status: str | None = Query(None), + owner_id: int | None = Query(None), + db: Session = Depends(get_db) +): + + request_id = getattr(request.state, "request_id", "N/A") + + query = db.query(Task) + + if status: + query = query.filter(Task.status == status) + + if owner_id: + query = query.filter(Task.owner_id == owner_id) + + tasks = query.all() + + logger.info( + "tasks fetched", + extra={"request_id": request_id} + ) + + return tasks + + +# ---------------- GET TASK BY ID ---------------- +@router.get( + "/{task_id}", + response_model=TaskResponse +) +def get_task( + task_id: int, + request: Request, + db: Session = Depends(get_db) +): + + request_id = getattr(request.state, "request_id", "N/A") + + task = db.query(Task).filter(Task.id == task_id).first() + + if not task: + logger.warning( + f"task not found id={task_id}", + extra={"request_id": request_id} + ) + raise HTTPException( + status_code=404, + detail="Task not found" + ) + + logger.info( + f"task fetched id={task_id}", + extra={"request_id": request_id} + ) + + return task + + +# ---------------- UPDATE TASK ---------------- +@router.put( + "/{task_id}", + response_model=TaskResponse +) +def update_task( + task_id: int, + request: Request, + payload: TaskUpdate, + db: Session = Depends(get_db) +): + + request_id = getattr(request.state, "request_id", "N/A") + + task = db.query(Task).filter(Task.id == task_id).first() + + if not task: + logger.warning( + f"task update failed id={task_id}", + extra={"request_id": request_id} + ) + raise HTTPException( + status_code=404, + detail="Task not found" + ) + + if payload.status and payload.status not in VALID_STATUS: + logger.warning( + f"invalid task status={payload.status}", + extra={"request_id": request_id} + ) + raise HTTPException( + status_code=400, + detail="Invalid task status" + ) + + update_data = payload.model_dump(exclude_unset=True) + + for key, value in update_data.items(): + setattr(task, key, value) + + db.commit() + db.refresh(task) + + logger.info( + f"task updated id={task_id}", + extra={"request_id": request_id} + ) + + return task + + +# ---------------- DELETE TASK ---------------- +@router.delete( + "/{task_id}", + status_code=status.HTTP_204_NO_CONTENT +) +def delete_task( + task_id: int, + request: Request, + db: Session = Depends(get_db) +): + + request_id = getattr(request.state, "request_id", "N/A") + + task = db.query(Task).filter(Task.id == task_id).first() + + if not task: + logger.warning( + f"task delete failed id={task_id}", + extra={"request_id": request_id} + ) + raise HTTPException( + status_code=404, + detail="Task not found" + ) + + db.delete(task) + db.commit() + + logger.info( + f"task deleted id={task_id}", + extra={"request_id": request_id} + ) + + return None \ No newline at end of file diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 0000000..d007e4f --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,102 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.user import User +from app.schemas.user import UserCreate, UserResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/users", tags=["Users"]) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=UserResponse) +def create_user( + request: Request, + payload: UserCreate, + db: Session = Depends(get_db) +): + + request_id = getattr(request.state, "request_id", "N/A") + + existing_user = db.query(User).filter( + User.email == payload.email + ).first() + + if existing_user: + logger.warning( + "user creation failed - email already exists", + extra={"request_id": request_id} + ) + + raise HTTPException( + status_code=409, + detail="Email already exists" + ) + + user = User( + name=payload.name, + email=payload.email + ) + + db.add(user) + db.commit() + db.refresh(user) + + logger.info( + f"user created id={user.id}", + extra={"request_id": request_id} + ) + + return UserResponse.model_validate(user) + + +@router.get("", response_model=list[UserResponse]) +def get_users( + request: Request, + db: Session = Depends(get_db) +): + + request_id = getattr(request.state, "request_id", "N/A") + + users = db.query(User).all() + + logger.info( + "users fetched", + extra={"request_id": request_id} + ) + + return users + + +@router.get("/{user_id}", response_model=UserResponse) +def get_user( + user_id: int, + request: Request, + db: Session = Depends(get_db) +): + + request_id = getattr(request.state, "request_id", "N/A") + + user = db.query(User).filter(User.id == user_id).first() + + if not user: + + logger.warning( + f"user not found id={user_id}", + extra={"request_id": request_id} + ) + + raise HTTPException( + status_code=404, + detail="User not found" + ) + + logger.info( + f"user fetched id={user_id}", + extra={"request_id": request_id} + ) + + return UserResponse.model_validate(user) \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..92e4c07 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,21 @@ +# app/core/config.py + +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import ValidationError + +class Settings(BaseSettings): + DATABASE_URL: str + LOG_LEVEL: str + APP_PORT: int + + model_config = SettingsConfigDict( + env_file=".env", + extra="ignore" + ) + +try: + settings = Settings() +except ValidationError as e: + raise RuntimeError( + f"Missing required environment variables: {e}" + ) \ No newline at end of file diff --git a/app/core/logger.py b/app/core/logger.py new file mode 100644 index 0000000..516e5b9 --- /dev/null +++ b/app/core/logger.py @@ -0,0 +1,34 @@ +import json +import logging +from datetime import datetime + + +class JsonFormatter(logging.Formatter): + def format(self, record): + log_record = { + "timestamp": datetime.utcnow().isoformat(), + "level": record.levelname, + "message": record.getMessage(), + "request_id": getattr( + record, + "request_id", + "N/A" + ) + } + + return json.dumps(log_record) + + +def setup_logger(): + logger = logging.getLogger() + + logger.setLevel(logging.INFO) + + logger.handlers.clear() + + handler = logging.StreamHandler() + handler.setFormatter(JsonFormatter()) + + logger.addHandler(handler) + + return logger \ No newline at end of file diff --git a/app/core/logging_config.py b/app/core/logging_config.py new file mode 100644 index 0000000..4e9837f --- /dev/null +++ b/app/core/logging_config.py @@ -0,0 +1,33 @@ +import json +import logging +from datetime import datetime + + +class JsonFormatter(logging.Formatter): + + def format(self, record): + + log_record = { + "timestamp": datetime.utcnow().isoformat(), + "level": record.levelname, + "message": record.getMessage(), + "request_id": getattr( + record, + "request_id", + "N/A" + ) + } + + return json.dumps(log_record) + + +def setup_logging(): + + handler = logging.StreamHandler() + handler.setFormatter(JsonFormatter()) + + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + + root_logger.handlers.clear() + root_logger.addHandler(handler) \ No newline at end of file diff --git a/app/core/startup.py b/app/core/startup.py new file mode 100644 index 0000000..ce0ae45 --- /dev/null +++ b/app/core/startup.py @@ -0,0 +1,14 @@ +from app.core.config import settings + + +def validate_settings(): + required = [ + settings.DATABASE_URL, + settings.APP_PORT, + settings.LOG_LEVEL + ] + + if not all(required): + raise ValueError( + "Required environment variables are missing" + ) \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..95ebc0c --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.orm import declarative_base + +Base = declarative_base() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..10727a2 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings + +engine = create_engine(settings.DATABASE_URL) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + + +def get_db(): + db = SessionLocal() + + try: + yield db + + finally: + db.close() \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..059ab2e --- /dev/null +++ b/app/main.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI +from app.core.startup import validate_settings +from app.core.logger import setup_logger + +from app.api.health import router as health_router +from app.api.ready import router as ready_router +from app.api.users import router as user_router +from app.api.tasks import router as task_router + +from app.middleware.request_id import RequestIDMiddleware +validate_settings() +logger = setup_logger() + +app = FastAPI( + title="TaskTracker API" +) +app.add_middleware(RequestIDMiddleware) + +app.include_router(health_router) +app.include_router(ready_router) +app.include_router(user_router) +app.include_router(task_router) \ No newline at end of file diff --git a/app/middleware/request_id.py b/app/middleware/request_id.py new file mode 100644 index 0000000..b68d3fc --- /dev/null +++ b/app/middleware/request_id.py @@ -0,0 +1,23 @@ +import uuid + +from starlette.middleware.base import BaseHTTPMiddleware + + +class RequestIDMiddleware(BaseHTTPMiddleware): + + async def dispatch( + self, + request, + call_next + ): + request.state.request_id = str( + uuid.uuid4() + ) + + response = await call_next(request) + + response.headers["X-Request-ID"] = ( + request.state.request_id + ) + + return response \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/task.py b/app/models/task.py new file mode 100644 index 0000000..d5bd08e --- /dev/null +++ b/app/models/task.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String, ForeignKey +from app.db.base import Base + + +class Task(Base): + __tablename__ = "tasks" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(200), nullable=False) + description = Column(String(500)) + status = Column(String(50), default="pending", nullable=False) + owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..be92d80 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, Integer, String +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + email = Column(String(100), unique=True, nullable=False) \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/task.py b/app/schemas/task.py new file mode 100644 index 0000000..b324936 --- /dev/null +++ b/app/schemas/task.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class TaskCreate(BaseModel): + title: str = Field(..., min_length=3, max_length=200) + description: Optional[str] = None + owner_id: int + + +class TaskUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=3, max_length=200) + description: Optional[str] = None + status: Optional[str] = None + + +class TaskResponse(BaseModel): + id: int + title: str + description: Optional[str] + status: str + owner_id: int + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..6750755 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, EmailStr, Field + + +class UserCreate(BaseModel): + name: str = Field(..., min_length=2, max_length=100) + email: EmailStr + + +class UserResponse(BaseModel): + id: int + name: str + email: str + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/task_service.py b/app/services/task_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..e69de29 diff --git a/create_project.py b/create_project.py new file mode 100644 index 0000000..61e7743 --- /dev/null +++ b/create_project.py @@ -0,0 +1,92 @@ +from pathlib import Path + +PROJECT_STRUCTURE = { + "app": { + "__init__.py": "", + "main.py": "", + "api": { + "__init__.py": "", + "users.py": "", + "tasks.py": "", + "health.py": "", + "ready.py": "", + }, + "core": { + "__init__.py": "", + "config.py": "", + "logger.py": "", + }, + "db": { + "__init__.py": "", + "base.py": "", + "session.py": "", + }, + "models": { + "__init__.py": "", + "user.py": "", + "task.py": "", + }, + "schemas": { + "__init__.py": "", + "user.py": "", + "task.py": "", + }, + "services": { + "__init__.py": "", + "user_service.py": "", + "task_service.py": "", + }, + }, + "tests": { + "__init__.py": "", + "conftest.py": "", + "test_users.py": "", + "test_tasks.py": "", + "test_health.py": "", + "test_ready.py": "", + }, + "scripts": { + "check_env.sh": "", + }, + "alembic": { + "versions": {}, + }, + ".github": { + "workflows": { + "ci.yml": "", + }, + "pull_request_template.md": "", + }, + ".env.example": "", + ".gitignore": "", + "requirements.txt": "", + "Makefile": "", + "Dockerfile": "", + "docker-compose.yml": "", + "alembic.ini": "", + "README.md": "", +} + + +def create_structure(base_path: Path, structure: dict): + for name, content in structure.items(): + path = base_path / name + + if isinstance(content, dict): + path.mkdir(parents=True, exist_ok=True) + create_structure(path, content) + else: + path.parent.mkdir(parents=True, exist_ok=True) + + if not path.exists(): + path.touch() + print(f"Created file: {path}") + else: + print(f"Exists: {path}") + + +if __name__ == "__main__": + root = Path.cwd() + create_structure(root, PROJECT_STRUCTURE) + + print("\n✅ TaskTracker folder structure created successfully.") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e96c888 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: "3.9" + +services: + + postgres: + image: postgres:16 + container_name: tasktracker-postgres + restart: always + environment: + POSTGRES_DB: tasktracker + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + + api: + build: . + container_name: tasktracker-api + depends_on: + - postgres + environment: + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/tasktracker + APP_PORT: 8000 + LOG_LEVEL: INFO + ports: + - "8000:8000" \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d919d0e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,511 @@ +# TaskTracker Backend Architecture + +## Overview + +TaskTracker is a REST API backend application built using FastAPI and PostgreSQL. + +The application provides APIs for: + +* User Management +* Task Management +* Health Monitoring +* Readiness Checks + +The architecture follows a layered design to ensure maintainability, scalability, and testability. + +--- + +# High Level Architecture + +```text + +------------------+ + | Client | + | Postman / UI App | + +--------+---------+ + | + v + +------------------+ + | FastAPI API | + +--------+---------+ + | + +--------------------+--------------------+ + | | + v v ++------------------+ +------------------+ +| Request Layer | | Middleware Layer | +| Routers | | Request ID | ++--------+---------+ +--------+---------+ + | | + +----------------+--------------------+ + | + v + +------------------+ + | Service Layer | + +--------+---------+ + | + v + +------------------+ + | Database Layer | + | SQLAlchemy ORM | + +--------+---------+ + | + v + +------------------+ + | PostgreSQL | + +------------------+ +``` + +--- + +# Project Structure + +```text +TaskTracker/ +│ +├── app/ +│ ├── api/ +│ │ ├── health.py +│ │ ├── ready.py +│ │ ├── users.py +│ │ └── tasks.py +│ │ +│ ├── core/ +│ │ ├── config.py +│ │ ├── logger.py +│ │ └── startup.py +│ │ +│ ├── db/ +│ │ ├── base.py +│ │ └── session.py +│ │ +│ ├── middleware/ +│ │ └── request_id.py +│ │ +│ ├── models/ +│ │ ├── user.py +│ │ └── task.py +│ │ +│ ├── schemas/ +│ │ ├── user.py +│ │ └── task.py +│ │ +│ ├── services/ +│ │ ├── user_service.py +│ │ └── task_service.py +│ │ +│ └── main.py +│ +├── tests/ +├── docs/ +│ └── architecture.md +│ +├── requirements.txt +├── README.md +└── Makefile +``` + +--- + +# Component Description + +## API Layer + +Location: + +```text +app/api/ +``` + +Responsibilities: + +* Receive HTTP requests +* Validate request payloads +* Call business logic +* Return API responses + +Endpoints: + +### User APIs + +```text +POST /users +GET /users +GET /users/{id} +``` + +### Task APIs + +```text +POST /tasks +GET /tasks +GET /tasks/{id} +PUT /tasks/{id} +DELETE /tasks/{id} +``` + +### Monitoring APIs + +```text +GET /healthz +GET /readyz +``` + +--- + +## Schema Layer + +Location: + +```text +app/schemas/ +``` + +Responsibilities: + +* Request validation +* Response serialization +* Input sanitization + +Examples: + +```python +UserCreate +UserResponse + +TaskCreate +TaskUpdate +TaskResponse +``` + +--- + +## Model Layer + +Location: + +```text +app/models/ +``` + +Responsibilities: + +* Database table definitions +* ORM mapping + +### User Table + +```text +users +``` + +Fields: + +```text +id +name +email +``` + +### Task Table + +```text +tasks +``` + +Fields: + +```text +id +title +description +status +owner_id +``` + +--- + +## Database Layer + +Location: + +```text +app/db/ +``` + +Responsibilities: + +* Database connection +* Session management +* Transaction handling + +Files: + +### base.py + +Responsible for: + +```text +SQLAlchemy Base Declaration +``` + +### session.py + +Responsible for: + +```text +Database Engine +SessionLocal +Dependency Injection +``` + +--- + +## Middleware Layer + +Location: + +```text +app/middleware/ +``` + +Responsibilities: + +* Request Tracking +* Request ID Generation + +Example: + +```text +X-Request-ID +``` + +Benefits: + +* Easier debugging +* Log correlation +* Distributed tracing support + +--- + +## Logging Layer + +Location: + +```text +app/core/logger.py +``` + +Responsibilities: + +* Structured logging +* Error logging +* Request tracing + +Example Log: + +```json +{ + "timestamp": "2026-01-01T10:00:00", + "level": "INFO", + "message": "task created", + "request_id": "abc123" +} +``` + +--- + +# Database Design + +## Entity Relationship Diagram + +```text ++---------+ +| Users | ++---------+ +| id | +| name | +| email | ++----+----+ + | + | + | One-to-Many + | + v ++------------+ +| Tasks | ++------------+ +| id | +| title | +| description| +| status | +| owner_id | ++------------+ +``` + +Relationship: + +```text +One User +can own +Many Tasks +``` + +--- + +# Request Flow + +## Create Task Flow + +```text +Client + | + v +POST /tasks + | + v +TaskCreate Schema Validation + | + v +Owner Validation + | + v +Task Model Creation + | + v +Database Commit + | + v +Response Returned +``` + +--- + +# Error Handling + +Supported Errors: + +## Validation Error + +```text +400 Bad Request +``` + +Example: + +```json +{ + "detail": "Invalid task status" +} +``` + +--- + +## Resource Not Found + +```text +404 Not Found +``` + +Example: + +```json +{ + "detail": "Task not found" +} +``` + +--- + +## Duplicate Resource + +```text +409 Conflict +``` + +Example: + +```json +{ + "detail": "Email already exists" +} +``` + +--- + +# Testing Strategy + +Framework: + +```text +pytest +``` + +Coverage Areas: + +* User APIs +* Task APIs +* Validation Rules +* Error Handling +* Health Checks +* Readiness Checks + +Current Target: + +```text +80%+ +``` + +--- + +# Deployment Architecture (Future) + +```text + Internet + | + v + +----------------+ + | Load Balancer | + +-------+--------+ + | + v + Kubernetes + | + +---------------+---------------+ + | | + v v ++-------------+ +-------------+ +| FastAPI Pod | | FastAPI Pod | ++------+------+ +------+------+ + | | + +---------------+---------------+ + | + v + PostgreSQL + | + v + Storage +``` + +Future Additions: + +* Docker +* Kubernetes +* Alembic +* CI/CD Pipeline +* Redis Cache +* JWT Authentication +* Monitoring & Alerting + +--- + +# Design Principles + +The application follows: + +* Separation of Concerns +* Dependency Injection +* Layered Architecture +* RESTful API Design +* Test-Driven Development +* Structured Logging +* Scalability First Approach diff --git a/docs/pipeline.md b/docs/pipeline.md new file mode 100644 index 0000000..c4e8d1e --- /dev/null +++ b/docs/pipeline.md @@ -0,0 +1,61 @@ +# CI/CD Pipeline Documentation + +## Overview + +TaskTracker uses GitHub Actions to automate testing, coverage reporting, and Docker image publishing. The workflow runs on pushes to the configured development and feature branches, as well as on pull requests. + +The pipeline executes automatically on: + +- Pushes to feature branches +- Pushes to `develop` +- Pushes to `main` +- Pull requests targeting the integration branches +--- + +## Pipeline Stages + + +### 1. Checkout + +The workflow checks out the latest version of the source code. + +### 2. Environment Setup + +Python 3.12 is installed, and all project dependencies are restored from `requirements.txt`. + +### 3. Testing + +The complete Pytest test suite is executed against a PostgreSQL 16 service container. + +### 4. Coverage + +Code coverage is generated using `pytest-cov` to ensure adequate test coverage. + +### 5. Image Publishing + +After all tests pass, the Docker image is built and pushed to Docker Hub from the configured release branches. + +--- + +## Required Secrets + +The following repository secrets must be configured: + +* `DOCKERHUB_USERNAME` +* `DOCKERHUB_TOKEN` + +--- + +## Published Image + +The latest image is available as: + +`/tasktracker:latest` + +Replace `` with your actual Docker Hub username. + +--- + +## Monitoring the Pipeline + +Workflow runs and logs are available in the **Actions** tab of the GitHub repository. diff --git a/make b/make new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2bebdde --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.ruff.lint.per-file-ignores] +"alembic/env.py" = ["E402", "F401"] \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ecb310f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +pytest +pytest-cov +httpx \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8b9fa60 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.116.1 +uvicorn==0.35.0 +sqlalchemy==2.0.41 +psycopg2-binary +pydantic +pydantic-settings +python-dotenv +email-validator +alembic +pytest-cov +pytest +httpx +Ruff \ No newline at end of file diff --git a/scripts/check_env.ps1 b/scripts/check_env.ps1 new file mode 100644 index 0000000..5f815dc --- /dev/null +++ b/scripts/check_env.ps1 @@ -0,0 +1,36 @@ +Write-Host "" +Write-Host "Environment Validation" +Write-Host "" + +python --version +git --version + +Write-Host "" + +try { + docker --version +} +catch { + Write-Host "Docker Not Installed" +} + +try { + kubectl version --client +} +catch { + Write-Host "Kubectl Not Installed" +} + +try { + helm version +} +catch { + Write-Host "Helm Not Installed" +} + +try { + terraform version +} +catch { + Write-Host "Terraform Not Installed" +} \ No newline at end of file diff --git a/scripts/check_env.sh b/scripts/check_env.sh new file mode 100644 index 0000000..e69de29 diff --git a/scripts/run_app.ps1 b/scripts/run_app.ps1 new file mode 100644 index 0000000..eeb478e --- /dev/null +++ b/scripts/run_app.ps1 @@ -0,0 +1,3 @@ +Write-Host "Starting FastAPI Application..." + +uvicorn app.main:app --reload \ No newline at end of file diff --git a/scripts/run_coverage.ps1 b/scripts/run_coverage.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/run_tests.ps1 b/scripts/run_tests.ps1 new file mode 100644 index 0000000..d213f59 --- /dev/null +++ b/scripts/run_tests.ps1 @@ -0,0 +1,3 @@ +Write-Host "Running Test Suite..." + +pytest -v \ No newline at end of file diff --git a/scripts/setup_dev.ps1 b/scripts/setup_dev.ps1 new file mode 100644 index 0000000..0ef4a46 --- /dev/null +++ b/scripts/setup_dev.ps1 @@ -0,0 +1,5 @@ +Write-Host "Installing dependencies..." + +pip install -r requirements.txt + +Write-Host "Setup completed." \ No newline at end of file diff --git a/test.db b/test.db new file mode 100644 index 0000000..5abcee8 Binary files /dev/null and b/test.db differ 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 new file mode 100644 index 0000000..3015c38 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from fastapi.testclient import TestClient + +from app.main import app +from app.db.base import Base +from app.db.session import get_db + + +# SQLite test DB (fast + no Docker needed) +SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +TestingSessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + + +@pytest.fixture(scope="session", autouse=True) +def setup_db(): + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + +@pytest.fixture(autouse=True) +def clean_db(): + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture +def client(): + return TestClient(app) \ No newline at end of file diff --git a/tests/test_db_session.py b/tests/test_db_session.py new file mode 100644 index 0000000..0039364 --- /dev/null +++ b/tests/test_db_session.py @@ -0,0 +1,15 @@ +from app.db.session import get_db + + +def test_get_db(): + + db_generator = get_db() + + db = next(db_generator) + + assert db is not None + + try: + next(db_generator) + except StopIteration: + pass \ No newline at end of file diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..d209723 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,13 @@ +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_healthz(): + response = client.get("/healthz") + + assert response.status_code == 200 + assert response.json() == { + "status": "ok" + } \ No newline at end of file diff --git a/tests/test_ready.py b/tests/test_ready.py new file mode 100644 index 0000000..fc5f71b --- /dev/null +++ b/tests/test_ready.py @@ -0,0 +1,10 @@ +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_readyz(): + response = client.get("/readyz") + + assert response.status_code in [200, 503] \ No newline at end of file diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..dd06af5 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,109 @@ + +def create_user(client): + response = client.post( + "/users", + json={ + "name": "Task Owner", + "email": "owner@test.com" + } + ) + + assert response.status_code == 201 + return response.json() + + +def create_task(client, owner_id): + response = client.post( + "/tasks", + json={ + "title": "Test Task", + "description": "Task description", + "owner_id": owner_id + } + ) + + assert response.status_code == 201 + return response + + +def test_create_task(client): + + user = create_user(client) + + response = create_task(client, user["id"]) + + assert response.status_code == 201 + data = response.json() + assert data["title"] == "Test Task" + assert data["owner_id"] == user["id"] + + +def test_create_task_invalid_owner(client): + + response = client.post( + "/tasks", + json={ + "title": "Invalid Task", + "description": "No owner", + "owner_id": 99999 + } + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "Owner not found" + + +def test_get_tasks(client): + + user = create_user(client) + create_task(client, user["id"]) + + response = client.get("/tasks") + + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +def test_get_task_by_id(client): + + user = create_user(client) + task = create_task(client, user["id"]).json() + + response = client.get(f"/tasks/{task['id']}") + + assert response.status_code == 200 + assert response.json()["id"] == task["id"] + + +def test_get_task_not_found(client): + + response = client.get("/tasks/999999") + + assert response.status_code == 404 + assert response.json()["detail"] == "Task not found" + + +def test_update_task_invalid_status(client): + + user = create_user(client) + task = create_task(client, user["id"]).json() + + response = client.put( + f"/tasks/{task['id']}", + json={ + "status": "wrong_status" + } + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid task status" + + +def test_delete_task(client): + + user = create_user(client) + task = create_task(client, user["id"]).json() + + response = client.delete(f"/tasks/{task['id']}") + + assert response.status_code == 204 \ No newline at end of file diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..d390cc9 --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,71 @@ + + +def test_create_user(client): + response = client.post( + "/users", + json={ + "name": "Aditya", + "email": "aditya@test.com" + } + ) + + assert response.status_code == 201 + data = response.json() + assert data["email"] == "aditya@test.com" + assert "id" in data + + +def test_create_user_duplicate_email(client): + + client.post( + "/users", + json={ + "name": "User1", + "email": "dup@test.com" + } + ) + + response = client.post( + "/users", + json={ + "name": "User2", + "email": "dup@test.com" + } + ) + + assert response.status_code == 409 + assert response.json()["detail"] == "Email already exists" + + +def test_get_users(client): + + response = client.get("/users") + + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +def test_get_user_by_id(client): + + create = client.post( + "/users", + json={ + "name": "Test User", + "email": "testuser@test.com" + } + ).json() + + user_id = create["id"] + + response = client.get(f"/users/{user_id}") + + assert response.status_code == 200 + assert response.json()["id"] == user_id + + +def test_get_user_not_found(client): + + response = client.get("/users/999999") + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" \ No newline at end of file diff --git "a/tructured JSON logging with request id\357\200\242" "b/tructured JSON logging with request id\357\200\242" new file mode 100644 index 0000000..3f839c1 --- /dev/null +++ "b/tructured JSON logging with request id\357\200\242" @@ -0,0 +1,18 @@ + dev + feature/alembic-migrations + feature/env-config + feature/health-ready-probes +* feature/json-logging + feature/project-setup + feature/user-task-api + main + stg + remotes/origin/HEAD -> origin/main + remotes/origin/dev + remotes/origin/feature/alembic-migrations + remotes/origin/feature/env-config + remotes/origin/feature/health-ready-probes + remotes/origin/feature/project-setup + remotes/origin/feature/user-task-api + remotes/origin/main + remotes/origin/stg