diff --git a/.github/workflows/feature_test_users_service.yml b/.github/workflows/feature_test_users_service.yml new file mode 100644 index 0000000..6935012 --- /dev/null +++ b/.github/workflows/feature_test_users_service.yml @@ -0,0 +1,36 @@ +name: Feature Branch CI - User Service + +on: + # Manual trigger + workflow_dispatch: + + # Workflow runs on any changes on Users Service, commited on feature or fix branches + push: + branches: + - "feature/**" + - "fix/**" + paths: + - "backend/users_service/**" + - ".github/workflows/*users_service*.yml" + + # Re-run the test when the new PR to develop is created + pull_request: + branches: + - "develop" + +jobs: + quality-checks: + name: Quality Check for Users Service + uses: ./.github/workflows/_reusable_quality_check_workflow.yml + secrets: inherit + with: + working-directory: "./backend/users_service" + linting-threshold: 8.0 + + test: + name: Run Tests for Notes Service + uses: ./.github/workflows/_reusable_test_workflow.yml + secrets: inherit + with: + working-directory: "./backend/users_service" + coverage-threshold: 80 \ No newline at end of file diff --git a/backend/users_service/.pylintrc b/backend/users_service/.pylintrc new file mode 100644 index 0000000..1f402fc --- /dev/null +++ b/backend/users_service/.pylintrc @@ -0,0 +1,2 @@ +[MESSAGES CONTROL] +disable=logging-fstring-interpolation \ No newline at end of file diff --git a/backend/users_service/Dockerfile b/backend/users_service/Dockerfile new file mode 100644 index 0000000..aa7c4f3 --- /dev/null +++ b/backend/users_service/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.10-slim-buster + +WORKDIR /code + +# Copy requirements and install +COPY requirements.txt . + +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code from app to /code/app +COPY app /code/app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/users_service/app/__init__.py b/backend/users_service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users_service/app/db.py b/backend/users_service/app/db.py new file mode 100644 index 0000000..ef6ae86 --- /dev/null +++ b/backend/users_service/app/db.py @@ -0,0 +1,31 @@ +import os + +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + + +POSTGRES_USER = os.getenv("POSTGRES_USER", "postgres") +POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "postgres") +POSTGRES_DB = os.getenv("POSTGRES_DB", "notes") +POSTGRES_HOST = os.getenv("POSTGRES_HOST", "localhost") +POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") + +DATABASE_URL = ( + "postgresql://" + f"{POSTGRES_USER}:{POSTGRES_PASSWORD}@" + f"{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" +) + +# --- SQLAlchemy Engine and Session Setup --- +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/users_service/app/main.py b/backend/users_service/app/main.py new file mode 100644 index 0000000..b212e4c --- /dev/null +++ b/backend/users_service/app/main.py @@ -0,0 +1,196 @@ +""" +Users Service API + +FastAPI application for user authentication and management +""" + +import logging +import sys +import time +from typing import List + +from fastapi import ( + Depends, + FastAPI, + HTTPException, + Query, + status, +) +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session + +from .db import Base, engine, get_db +from .models import User +from .schemas import UserCreate, UserResponse + +# --- Logging Configuration --- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger(__name__) + +# Suppress noisy logs from third-party libraries for cleaner output +logging.getLogger("uvicorn.access").setLevel(logging.WARNING) +logging.getLogger("uvicorn.error").setLevel(logging.INFO) + +# --- FastAPI Application Setup --- +app = FastAPI( + title="Users Service API", + description="Manages users for multi-user note-taking application", + version="1.0.0", +) + +# Enable CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# --- Startup Event --- +@app.on_event("startup") +async def startup_event(): + max_retries = 10 + retry_delay_seconds = 5 + for i in range(max_retries): + try: + logger.info( + f"Users Service: Attempting to connect to PostgreSQL and create tables (attempt {i+1}/{max_retries})..." + ) + Base.metadata.create_all(bind=engine) + logger.info( + "Users Service: Successfully connected to PostgreSQL and ensured tables exist." + ) + break # Exit loop if successful + except OperationalError as e: + logger.warning(f"Users Service: Failed to connect to PostgreSQL: {e}") + if i < max_retries - 1: + logger.info( + f"Users Service: Retrying in {retry_delay_seconds} seconds..." + ) + time.sleep(retry_delay_seconds) + else: + logger.critical( + f"Users Service: Failed to connect to PostgreSQL after {max_retries} attempts. Exiting application." + ) + sys.exit(1) # Critical failure: exit if DB connection is unavailable + except Exception as e: + logger.critical( + f"Users Service: An unexpected error occurred during database startup: {e}", + exc_info=True, + ) + sys.exit(1) + + +# --- Root Endpoint --- +@app.get("/", status_code=status.HTTP_200_OK, summary="Root endpoint") +async def read_root(): + return {"message": "Welcome to the Users Service!"} + + +# --- Health Check Endpoint --- +@app.get("/health", status_code=status.HTTP_200_OK, summary="Health check") +async def health_check(): + return {"status": "ok", "service": "users-service"} + + +# --- CRUD Endpoints --- +# Create new user (Register) +# [POST] http://localhost:8001/users/ +""" +{ + "username": "johndoe", + "email": "john@example.com" +} +""" + + +@app.post( + "/users/", + response_model=UserResponse, + status_code=status.HTTP_201_CREATED, + summary="Register a new user", +) +async def create_user(user: UserCreate, db: Session = Depends(get_db)): + """Register a new user.""" + logger.info(f"Users Service: Creating user: {user.username}") + + # Check if username exists + existing_user = db.query(User).filter(User.username == user.username).first() + if existing_user: + logger.warning(f"Users Service: Username {user.username} already exists") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Username already exists" + ) + + # Check if email exists + existing_email = db.query(User).filter(User.email == user.email).first() + if existing_email: + logger.warning(f"Users Service: Email {user.email} already exists") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Email already exists" + ) + + try: + db_user = User(username=user.username, email=user.email) + db.add(db_user) + db.commit() + db.refresh(db_user) + logger.info( + f"Users Service: User '{db_user.username}' (ID: {db_user.id}) created successfully." + ) + return db_user + except Exception as e: + db.rollback() + logger.error(f"Users Service: Error creating user: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not create user.", + ) + + +# Get user by ID +# [GET] http://localhost:8001/users/{user_id} +@app.get( + "/users/{user_id}", + response_model=UserResponse, + summary="Get a single user by ID", +) +def get_user(user_id: int, db: Session = Depends(get_db)): + """Retrieve a specific user by ID.""" + logger.info(f"Users Service: Fetching user with ID: {user_id}") + user = db.query(User).filter(User.id == user_id).first() + + if not user: + logger.warning(f"Users Service: User with ID {user_id} not found.") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + + logger.info(f"Users Service: Retrieved user with ID {user_id}") + return user + + +# Get all users +# [GET] http://localhost:8001/users/ +@app.get( + "/users/", + response_model=List[UserResponse], + summary="Get all users", +) +def list_users( + db: Session = Depends(get_db), + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), +): + """Retrieve all users.""" + logger.info(f"Users Service: Listing users with skip={skip}, limit={limit}") + users = db.query(User).offset(skip).limit(limit).all() + logger.info(f"Users Service: Retrieved {len(users)} users") + return users diff --git a/backend/users_service/app/models.py b/backend/users_service/app/models.py new file mode 100644 index 0000000..6a1031e --- /dev/null +++ b/backend/users_service/app/models.py @@ -0,0 +1,21 @@ +"""SQLAlchemy models.""" + +from sqlalchemy import Column, DateTime, Integer, String +from sqlalchemy.sql import func +from .db import Base + + +class User(Base): # pylint: disable=too-few-public-methods + """User model.""" + + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False, index=True) + email = Column(String(255), unique=True, nullable=False) + created_at = Column( + DateTime(timezone=True), server_default=func.now() + ) # pylint: disable=not-callable + + def __repr__(self): + return f"" diff --git a/backend/users_service/app/schemas.py b/backend/users_service/app/schemas.py new file mode 100644 index 0000000..f9c87bd --- /dev/null +++ b/backend/users_service/app/schemas.py @@ -0,0 +1,22 @@ +"""Pydantic schemas.""" + +from datetime import datetime +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class UserCreate(BaseModel): + """Schema for creating user.""" + + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + + +class UserResponse(BaseModel): + """Schema for user response.""" + + id: int + username: str + email: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/users_service/requirements-dev.txt b/backend/users_service/requirements-dev.txt new file mode 100644 index 0000000..54aac29 --- /dev/null +++ b/backend/users_service/requirements-dev.txt @@ -0,0 +1,19 @@ +fastapi +uvicorn +sqlalchemy +psycopg2-binary +python-multipart +pydantic +azure-storage-blob +aio-pika +pydantic[email] + +# Testing and coverage report +pytest +pytest-cov +httpx + +# Code quality +black # Linting & format code +pylint # Code quality +bandit # Security linting \ No newline at end of file diff --git a/backend/users_service/requirements.txt b/backend/users_service/requirements.txt new file mode 100644 index 0000000..0820218 --- /dev/null +++ b/backend/users_service/requirements.txt @@ -0,0 +1,9 @@ +fastapi +uvicorn +sqlalchemy +psycopg2-binary +python-multipart +pydantic +azure-storage-blob +aio-pika +pydantic[email] diff --git a/backend/users_service/tests/__init__.py b/backend/users_service/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users_service/tests/conftest.py b/backend/users_service/tests/conftest.py new file mode 100644 index 0000000..bebbd03 --- /dev/null +++ b/backend/users_service/tests/conftest.py @@ -0,0 +1,101 @@ +import logging +import os +import time +import pytest +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session +from fastapi.testclient import TestClient + +from app.main import app +from app.db import Base, engine, SessionLocal, get_db +from app.models import User + +# Suppress noisy logs from SQLAlchemy/FastAPI during tests for cleaner output +logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) +logging.getLogger("uvicorn.access").setLevel(logging.WARNING) +logging.getLogger("uvicorn.error").setLevel(logging.WARNING) +logging.getLogger("fastapi").setLevel(logging.WARNING) +logging.getLogger("app.main").setLevel(logging.WARNING) + + +@pytest.fixture(scope="session", autouse=True) +def setup_database_for_tests(): + """Set up test database with retry logic""" + max_retries = 10 + retry_delay_seconds = 3 + + for i in range(max_retries): + try: + logging.info( + f"Users Service Tests: Attempting to connect to PostgreSQL for test setup (attempt {i+1}/{max_retries})..." + ) + + # Explicitly drop all tables first to ensure a clean slate for the session + Base.metadata.drop_all(bind=engine) + logging.info( + "Users Service Tests: Successfully dropped all tables in PostgreSQL for test setup." + ) + + # Then create all tables required by the application + Base.metadata.create_all(bind=engine) + logging.info( + "Users Service Tests: Successfully created all tables in PostgreSQL for test setup." + ) + break + except OperationalError as e: + logging.warning( + f"Users Service Tests: Test setup DB connection failed: {e}. Retrying in {retry_delay_seconds} seconds..." + ) + time.sleep(retry_delay_seconds) + if i == max_retries - 1: + pytest.fail( + f"Could not connect to PostgreSQL for Product Service test setup after {max_retries} attempts: {e}" + ) + except Exception as e: + pytest.fail( + f"Users Service Tests: An unexpected error occurred during test DB setup: {e}", + pytrace=True, + ) + yield + + +@pytest.fixture(scope="function") +def db_session_for_test(): + """Provide isolated database session for each test""" + connection = engine.connect() + transaction = connection.begin() + db = SessionLocal(bind=connection) + + def override_get_db(): + yield db + + app.dependency_overrides[get_db] = override_get_db + + try: + yield db + finally: + transaction.rollback() + db.close() + connection.close() + app.dependency_overrides.pop(get_db, None) + + +@pytest.fixture(scope="module") +def client(): + """ + Provides a TestClient for making HTTP requests to the FastAPI application. + The TestClient automatically manages the app's lifespan events (startup/shutdown). + """ + os.environ["AZURE_STORAGE_ACCOUNT_NAME"] = "testaccount" + os.environ["AZURE_STORAGE_ACCOUNT_KEY"] = "testkey" + os.environ["AZURE_STORAGE_CONTAINER_NAME"] = "test-images" + os.environ["AZURE_SAS_TOKEN_EXPIRY_HOURS"] = "1" + + with TestClient(app) as test_client: + yield test_client + + # Clean up environment variables after tests + del os.environ["AZURE_STORAGE_ACCOUNT_NAME"] + del os.environ["AZURE_STORAGE_ACCOUNT_KEY"] + del os.environ["AZURE_STORAGE_CONTAINER_NAME"] + del os.environ["AZURE_SAS_TOKEN_EXPIRY_HOURS"] diff --git a/backend/users_service/tests/integration/__init__.py b/backend/users_service/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users_service/tests/integration/test_users_api.py b/backend/users_service/tests/integration/test_users_api.py new file mode 100644 index 0000000..742b639 --- /dev/null +++ b/backend/users_service/tests/integration/test_users_api.py @@ -0,0 +1,126 @@ +"""Integration tests for Users Service API.""" + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from app.models import User + + +def test_read_root(client: TestClient): + """Test root endpoint.""" + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "Welcome to the Users Service!"} + + +def test_health_check(client: TestClient): + """Test health check endpoint.""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "service": "users-service"} + + +def test_create_user_success(client: TestClient, db_session_for_test: Session): + """Test successful user creation.""" + test_data = {"username": "johndoe", "email": "john@example.com"} + response = client.post("/users/", json=test_data) + + assert response.status_code == 201 + data = response.json() + assert data["username"] == test_data["username"] + assert data["email"] == test_data["email"] + assert "id" in data + assert "created_at" in data + + # Verify in database + db_user = db_session_for_test.query(User).filter(User.id == data["id"]).first() + assert db_user is not None + assert db_user.username == test_data["username"] + + +def test_create_user_duplicate_username( + client: TestClient, db_session_for_test: Session +): + """Test creating user with duplicate username.""" + test_data = {"username": "duplicate", "email": "user1@example.com"} + + # Create first user + response1 = client.post("/users/", json=test_data) + assert response1.status_code == 201 + + # Try to create second user with same username + test_data2 = {"username": "duplicate", "email": "user2@example.com"} + response2 = client.post("/users/", json=test_data2) + assert response2.status_code == 409 + assert "Username already exists" in response2.json()["detail"] + + +def test_create_user_duplicate_email(client: TestClient, db_session_for_test: Session): + """Test creating user with duplicate email.""" + test_data = {"username": "user1", "email": "duplicate@example.com"} + + # Create first user + response1 = client.post("/users/", json=test_data) + assert response1.status_code == 201 + + # Try to create second user with same email + test_data2 = {"username": "user2", "email": "duplicate@example.com"} + response2 = client.post("/users/", json=test_data2) + assert response2.status_code == 409 + assert "Email already exists" in response2.json()["detail"] + + +def test_create_user_invalid_email(client: TestClient): + """Test creating user with invalid email format.""" + invalid_data = {"username": "testuser", "email": "invalid-email"} + response = client.post("/users/", json=invalid_data) + assert response.status_code == 422 + + +def test_create_user_short_username(client: TestClient): + """Test creating user with too short username.""" + invalid_data = {"username": "ab", "email": "test@example.com"} + response = client.post("/users/", json=invalid_data) + assert response.status_code == 422 + + +def test_get_user_success(client: TestClient, db_session_for_test: Session): + """Test getting user by ID.""" + # Create user first + create_response = client.post( + "/users/", json={"username": "gettest", "email": "get@example.com"} + ) + user_id = create_response.json()["id"] + + # Get user + response = client.get(f"/users/{user_id}") + assert response.status_code == 200 + assert response.json()["id"] == user_id + assert response.json()["username"] == "gettest" + + +def test_get_user_not_found(client: TestClient): + """Test getting non-existent user.""" + response = client.get("/users/99999") + assert response.status_code == 404 + assert "User not found" in response.json()["detail"] + + +def test_list_users_empty(client: TestClient, db_session_for_test: Session): + """Test listing users when database is empty.""" + response = client.get("/users/") + assert response.status_code == 200 + # Note: may have users from other tests, so just check it's a list + assert isinstance(response.json(), list) + + +def test_list_users_with_data(client: TestClient, db_session_for_test: Session): + """Test listing users with data.""" + # Create users + client.post("/users/", json={"username": "user1", "email": "user1@example.com"}) + client.post("/users/", json={"username": "user2", "email": "user2@example.com"}) + + # List users + response = client.get("/users/") + assert response.status_code == 200 + assert isinstance(response.json(), list) + assert len(response.json()) >= 2 diff --git a/backend/users_service/tests/unit/__init__.py b/backend/users_service/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users_service/tests/unit/test_models.py b/backend/users_service/tests/unit/test_models.py new file mode 100644 index 0000000..b99eca2 --- /dev/null +++ b/backend/users_service/tests/unit/test_models.py @@ -0,0 +1,12 @@ +"""Unit tests for SQLAlchemy models.""" + +from app.models import User + + +def test_user_repr(): + """Test user model string representation.""" + user = User(id=1, username="testuser", email="test@example.com") + repr_str = repr(user) + assert "User" in repr_str + assert "id=1" in repr_str + assert "testuser" in repr_str diff --git a/backend/users_service/tests/unit/test_schemas.py b/backend/users_service/tests/unit/test_schemas.py new file mode 100644 index 0000000..caf4a74 --- /dev/null +++ b/backend/users_service/tests/unit/test_schemas.py @@ -0,0 +1,36 @@ +"""Unit tests for Pydantic schemas.""" + +import pytest +from pydantic import ValidationError +from app.schemas import UserCreate + + +def test_user_create_valid(): + """Test valid user creation schema.""" + user = UserCreate(username="testuser", email="test@example.com") + assert user.username == "testuser" + assert user.email == "test@example.com" + + +def test_user_create_invalid_email(): + """Test user creation with invalid email.""" + with pytest.raises(ValidationError): + UserCreate(username="testuser", email="invalid-email") + + +def test_user_create_short_username(): + """Test user creation with username too short.""" + with pytest.raises(ValidationError): + UserCreate(username="ab", email="test@example.com") + + +def test_user_create_long_username(): + """Test user creation with username too long.""" + with pytest.raises(ValidationError): + UserCreate(username="a" * 51, email="test@example.com") + + +def test_user_create_empty_username(): + """Test user creation with empty username.""" + with pytest.raises(ValidationError): + UserCreate(username="", email="test@example.com") diff --git a/docker-compose.yml b/docker-compose.yml index 10ac0ad..4ae86da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,41 @@ version: '3.8' services: - # notes-service: - # build: ./backend/notes_service - # ports: - # - "8000:8000" - # environment: - # - POSTGRES_USER=postgres - # - POSTGRES_PASSWORD=postgres - # - POSTGRES_DB=notes - # - POSTGRES_HOST=postgres - # - POSTGRES_PORT=5432 - # depends_on: - # - postgres - # command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + notes-service: + build: ./backend/notes_service + ports: + - "8000:8000" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=notes + - POSTGRES_HOST=postgres + - POSTGRES_PORT=5432 + depends_on: + - postgres-notes + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + volumes: + - ./backend/notes_service/app:/code/app + + users-service: + build: ./backend/users_service + ports: + - "8001:8000" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=users + - POSTGRES_HOST=postgres + - POSTGRES_PORT=5432 + depends_on: + - postgres-users + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + volumes: + - ./backend/users_service/app:/code/app - postgres: + postgres-notes: image: postgres:15-alpine + container_name: postgres-notes environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres @@ -25,7 +44,20 @@ services: - "5432:5432" volumes: - notes_db_data:/var/lib/postgresql/data + + postgres-users: + image: postgres:15-alpine + container_name: postgres-users + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=users + ports: + - "5433:5432" # Different host port to avoid conflict + volumes: + - users_db_data:/var/lib/postgresql/data # Persistent Volume volumes: - notes_db_data: \ No newline at end of file + notes_db_data: + users_db_data: \ No newline at end of file