From ef898ff57bd0ace3df14c3a2fe802cbac5e9fb74 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 2 Oct 2025 21:28:19 +1000 Subject: [PATCH 1/4] feat(users): add users service with automate tests and feature branch CI --- .../workflows/feature_test_notes_service.yml | 3 +- .../workflows/feature_test_users_service.yml | 28 +++ backend/user_service/.pylintrc | 2 + backend/user_service/Dockerfile | 16 ++ backend/user_service/app/__init__.py | 0 backend/user_service/app/db.py | 31 +++ backend/user_service/app/main.py | 197 ++++++++++++++++++ backend/user_service/app/models.py | 18 ++ backend/user_service/app/schemas.py | 19 ++ backend/user_service/requirements-dev.txt | 19 ++ backend/user_service/requirements.txt | 9 + backend/user_service/tests/__init__.py | 0 backend/user_service/tests/conftest.py | 101 +++++++++ .../tests/integration/__init__.py | 0 .../tests/integration/test_users_api.py | 128 ++++++++++++ backend/user_service/tests/unit/__init__.py | 0 .../user_service/tests/unit/test_models.py | 11 + .../user_service/tests/unit/test_schemas.py | 35 ++++ docker-compose.yml | 62 ++++-- 19 files changed, 663 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/feature_test_users_service.yml create mode 100644 backend/user_service/.pylintrc create mode 100644 backend/user_service/Dockerfile create mode 100644 backend/user_service/app/__init__.py create mode 100644 backend/user_service/app/db.py create mode 100644 backend/user_service/app/main.py create mode 100644 backend/user_service/app/models.py create mode 100644 backend/user_service/app/schemas.py create mode 100644 backend/user_service/requirements-dev.txt create mode 100644 backend/user_service/requirements.txt create mode 100644 backend/user_service/tests/__init__.py create mode 100644 backend/user_service/tests/conftest.py create mode 100644 backend/user_service/tests/integration/__init__.py create mode 100644 backend/user_service/tests/integration/test_users_api.py create mode 100644 backend/user_service/tests/unit/__init__.py create mode 100644 backend/user_service/tests/unit/test_models.py create mode 100644 backend/user_service/tests/unit/test_schemas.py diff --git a/.github/workflows/feature_test_notes_service.yml b/.github/workflows/feature_test_notes_service.yml index afcf63e..e60757b 100644 --- a/.github/workflows/feature_test_notes_service.yml +++ b/.github/workflows/feature_test_notes_service.yml @@ -8,6 +8,7 @@ on: - "fix/**" paths: - "backend/notes_service/**" + - ".github/workflows/*notes_service*.yml" jobs: quality-checks: @@ -16,7 +17,7 @@ jobs: secrets: inherit with: working-directory: "./backend/notes_service" - linting-threshold: 6.0 + linting-threshold: 8.0 test: name: Run Tests for Notes Service diff --git a/.github/workflows/feature_test_users_service.yml b/.github/workflows/feature_test_users_service.yml new file mode 100644 index 0000000..22fb4a4 --- /dev/null +++ b/.github/workflows/feature_test_users_service.yml @@ -0,0 +1,28 @@ +name: Feature Branch CI - User Service + +# Workflow runs on any changes on Note Services, commited on feature or fix branches +on: + push: + branches: + - "feature/**" + - "fix/**" + paths: + - "backend/users_service/**" + - ".github/workflows/*users_service*.yml" + +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/user_service/.pylintrc b/backend/user_service/.pylintrc new file mode 100644 index 0000000..1f402fc --- /dev/null +++ b/backend/user_service/.pylintrc @@ -0,0 +1,2 @@ +[MESSAGES CONTROL] +disable=logging-fstring-interpolation \ No newline at end of file diff --git a/backend/user_service/Dockerfile b/backend/user_service/Dockerfile new file mode 100644 index 0000000..aa7c4f3 --- /dev/null +++ b/backend/user_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/user_service/app/__init__.py b/backend/user_service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/user_service/app/db.py b/backend/user_service/app/db.py new file mode 100644 index 0000000..ef6ae86 --- /dev/null +++ b/backend/user_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/user_service/app/main.py b/backend/user_service/app/main.py new file mode 100644 index 0000000..0f19e2d --- /dev/null +++ b/backend/user_service/app/main.py @@ -0,0 +1,197 @@ +""" +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 \ No newline at end of file diff --git a/backend/user_service/app/models.py b/backend/user_service/app/models.py new file mode 100644 index 0000000..ec229cb --- /dev/null +++ b/backend/user_service/app/models.py @@ -0,0 +1,18 @@ +"""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"" \ No newline at end of file diff --git a/backend/user_service/app/schemas.py b/backend/user_service/app/schemas.py new file mode 100644 index 0000000..f0396f1 --- /dev/null +++ b/backend/user_service/app/schemas.py @@ -0,0 +1,19 @@ +"""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) \ No newline at end of file diff --git a/backend/user_service/requirements-dev.txt b/backend/user_service/requirements-dev.txt new file mode 100644 index 0000000..54aac29 --- /dev/null +++ b/backend/user_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/user_service/requirements.txt b/backend/user_service/requirements.txt new file mode 100644 index 0000000..0820218 --- /dev/null +++ b/backend/user_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/user_service/tests/__init__.py b/backend/user_service/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/user_service/tests/conftest.py b/backend/user_service/tests/conftest.py new file mode 100644 index 0000000..bebbd03 --- /dev/null +++ b/backend/user_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/user_service/tests/integration/__init__.py b/backend/user_service/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/user_service/tests/integration/test_users_api.py b/backend/user_service/tests/integration/test_users_api.py new file mode 100644 index 0000000..b6196e1 --- /dev/null +++ b/backend/user_service/tests/integration/test_users_api.py @@ -0,0 +1,128 @@ +"""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/user_service/tests/unit/__init__.py b/backend/user_service/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/user_service/tests/unit/test_models.py b/backend/user_service/tests/unit/test_models.py new file mode 100644 index 0000000..fd73fb8 --- /dev/null +++ b/backend/user_service/tests/unit/test_models.py @@ -0,0 +1,11 @@ +"""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 \ No newline at end of file diff --git a/backend/user_service/tests/unit/test_schemas.py b/backend/user_service/tests/unit/test_schemas.py new file mode 100644 index 0000000..681a8a7 --- /dev/null +++ b/backend/user_service/tests/unit/test_schemas.py @@ -0,0 +1,35 @@ +"""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") \ No newline at end of file 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 From 7a281546b3d983d360c572aaf44d61672d8ef4e0 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 2 Oct 2025 21:46:27 +1000 Subject: [PATCH 2/4] fix(users): fix naming issue of users_service --- backend/{user_service => users_service}/.pylintrc | 0 backend/{user_service => users_service}/Dockerfile | 0 backend/{user_service => users_service}/app/__init__.py | 0 backend/{user_service => users_service}/app/db.py | 0 backend/{user_service => users_service}/app/main.py | 0 backend/{user_service => users_service}/app/models.py | 0 backend/{user_service => users_service}/app/schemas.py | 0 backend/{user_service => users_service}/requirements-dev.txt | 0 backend/{user_service => users_service}/requirements.txt | 0 backend/{user_service => users_service}/tests/__init__.py | 0 backend/{user_service => users_service}/tests/conftest.py | 0 .../{user_service => users_service}/tests/integration/__init__.py | 0 .../tests/integration/test_users_api.py | 0 backend/{user_service => users_service}/tests/unit/__init__.py | 0 backend/{user_service => users_service}/tests/unit/test_models.py | 0 .../{user_service => users_service}/tests/unit/test_schemas.py | 0 16 files changed, 0 insertions(+), 0 deletions(-) rename backend/{user_service => users_service}/.pylintrc (100%) rename backend/{user_service => users_service}/Dockerfile (100%) rename backend/{user_service => users_service}/app/__init__.py (100%) rename backend/{user_service => users_service}/app/db.py (100%) rename backend/{user_service => users_service}/app/main.py (100%) rename backend/{user_service => users_service}/app/models.py (100%) rename backend/{user_service => users_service}/app/schemas.py (100%) rename backend/{user_service => users_service}/requirements-dev.txt (100%) rename backend/{user_service => users_service}/requirements.txt (100%) rename backend/{user_service => users_service}/tests/__init__.py (100%) rename backend/{user_service => users_service}/tests/conftest.py (100%) rename backend/{user_service => users_service}/tests/integration/__init__.py (100%) rename backend/{user_service => users_service}/tests/integration/test_users_api.py (100%) rename backend/{user_service => users_service}/tests/unit/__init__.py (100%) rename backend/{user_service => users_service}/tests/unit/test_models.py (100%) rename backend/{user_service => users_service}/tests/unit/test_schemas.py (100%) diff --git a/backend/user_service/.pylintrc b/backend/users_service/.pylintrc similarity index 100% rename from backend/user_service/.pylintrc rename to backend/users_service/.pylintrc diff --git a/backend/user_service/Dockerfile b/backend/users_service/Dockerfile similarity index 100% rename from backend/user_service/Dockerfile rename to backend/users_service/Dockerfile diff --git a/backend/user_service/app/__init__.py b/backend/users_service/app/__init__.py similarity index 100% rename from backend/user_service/app/__init__.py rename to backend/users_service/app/__init__.py diff --git a/backend/user_service/app/db.py b/backend/users_service/app/db.py similarity index 100% rename from backend/user_service/app/db.py rename to backend/users_service/app/db.py diff --git a/backend/user_service/app/main.py b/backend/users_service/app/main.py similarity index 100% rename from backend/user_service/app/main.py rename to backend/users_service/app/main.py diff --git a/backend/user_service/app/models.py b/backend/users_service/app/models.py similarity index 100% rename from backend/user_service/app/models.py rename to backend/users_service/app/models.py diff --git a/backend/user_service/app/schemas.py b/backend/users_service/app/schemas.py similarity index 100% rename from backend/user_service/app/schemas.py rename to backend/users_service/app/schemas.py diff --git a/backend/user_service/requirements-dev.txt b/backend/users_service/requirements-dev.txt similarity index 100% rename from backend/user_service/requirements-dev.txt rename to backend/users_service/requirements-dev.txt diff --git a/backend/user_service/requirements.txt b/backend/users_service/requirements.txt similarity index 100% rename from backend/user_service/requirements.txt rename to backend/users_service/requirements.txt diff --git a/backend/user_service/tests/__init__.py b/backend/users_service/tests/__init__.py similarity index 100% rename from backend/user_service/tests/__init__.py rename to backend/users_service/tests/__init__.py diff --git a/backend/user_service/tests/conftest.py b/backend/users_service/tests/conftest.py similarity index 100% rename from backend/user_service/tests/conftest.py rename to backend/users_service/tests/conftest.py diff --git a/backend/user_service/tests/integration/__init__.py b/backend/users_service/tests/integration/__init__.py similarity index 100% rename from backend/user_service/tests/integration/__init__.py rename to backend/users_service/tests/integration/__init__.py diff --git a/backend/user_service/tests/integration/test_users_api.py b/backend/users_service/tests/integration/test_users_api.py similarity index 100% rename from backend/user_service/tests/integration/test_users_api.py rename to backend/users_service/tests/integration/test_users_api.py diff --git a/backend/user_service/tests/unit/__init__.py b/backend/users_service/tests/unit/__init__.py similarity index 100% rename from backend/user_service/tests/unit/__init__.py rename to backend/users_service/tests/unit/__init__.py diff --git a/backend/user_service/tests/unit/test_models.py b/backend/users_service/tests/unit/test_models.py similarity index 100% rename from backend/user_service/tests/unit/test_models.py rename to backend/users_service/tests/unit/test_models.py diff --git a/backend/user_service/tests/unit/test_schemas.py b/backend/users_service/tests/unit/test_schemas.py similarity index 100% rename from backend/user_service/tests/unit/test_schemas.py rename to backend/users_service/tests/unit/test_schemas.py From 109029c0b87fd9a062b1b5298886ebc9b2e6083c Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 2 Oct 2025 21:50:31 +1000 Subject: [PATCH 3/4] fix(users): update code to pass format checking using black --- backend/users_service/app/main.py | 13 ++++++------- backend/users_service/app/models.py | 13 ++++++++----- backend/users_service/app/schemas.py | 7 +++++-- .../tests/integration/test_users_api.py | 18 ++++++++---------- .../users_service/tests/unit/test_models.py | 3 ++- .../users_service/tests/unit/test_schemas.py | 3 ++- 6 files changed, 31 insertions(+), 26 deletions(-) diff --git a/backend/users_service/app/main.py b/backend/users_service/app/main.py index 0f19e2d..b212e4c 100644 --- a/backend/users_service/app/main.py +++ b/backend/users_service/app/main.py @@ -109,6 +109,8 @@ async def health_check(): "email": "john@example.com" } """ + + @app.post( "/users/", response_model=UserResponse, @@ -124,8 +126,7 @@ async def create_user(user: UserCreate, db: Session = Depends(get_db)): 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" + status_code=status.HTTP_409_CONFLICT, detail="Username already exists" ) # Check if email exists @@ -133,8 +134,7 @@ async def create_user(user: UserCreate, db: Session = Depends(get_db)): 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" + status_code=status.HTTP_409_CONFLICT, detail="Email already exists" ) try: @@ -170,8 +170,7 @@ def get_user(user_id: int, db: Session = Depends(get_db)): 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" + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) logger.info(f"Users Service: Retrieved user with ID {user_id}") @@ -194,4 +193,4 @@ def list_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 \ No newline at end of file + return users diff --git a/backend/users_service/app/models.py b/backend/users_service/app/models.py index ec229cb..6a1031e 100644 --- a/backend/users_service/app/models.py +++ b/backend/users_service/app/models.py @@ -1,4 +1,5 @@ """SQLAlchemy models.""" + from sqlalchemy import Column, DateTime, Integer, String from sqlalchemy.sql import func from .db import Base @@ -6,13 +7,15 @@ 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 - + created_at = Column( + DateTime(timezone=True), server_default=func.now() + ) # pylint: disable=not-callable + def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/backend/users_service/app/schemas.py b/backend/users_service/app/schemas.py index f0396f1..f9c87bd 100644 --- a/backend/users_service/app/schemas.py +++ b/backend/users_service/app/schemas.py @@ -1,19 +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) \ No newline at end of file + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/users_service/tests/integration/test_users_api.py b/backend/users_service/tests/integration/test_users_api.py index b6196e1..742b639 100644 --- a/backend/users_service/tests/integration/test_users_api.py +++ b/backend/users_service/tests/integration/test_users_api.py @@ -1,4 +1,5 @@ """Integration tests for Users Service API.""" + from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.models import User @@ -31,19 +32,17 @@ def test_create_user_success(client: TestClient, db_session_for_test: Session): assert "created_at" in data # Verify in database - db_user = ( - db_session_for_test.query(User) - .filter(User.id == data["id"]) - .first() - ) + 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): +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 @@ -58,7 +57,7 @@ def test_create_user_duplicate_username(client: TestClient, db_session_for_test: 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 @@ -88,8 +87,7 @@ 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"} + "/users/", json={"username": "gettest", "email": "get@example.com"} ) user_id = create_response.json()["id"] diff --git a/backend/users_service/tests/unit/test_models.py b/backend/users_service/tests/unit/test_models.py index fd73fb8..b99eca2 100644 --- a/backend/users_service/tests/unit/test_models.py +++ b/backend/users_service/tests/unit/test_models.py @@ -1,4 +1,5 @@ """Unit tests for SQLAlchemy models.""" + from app.models import User @@ -8,4 +9,4 @@ def test_user_repr(): repr_str = repr(user) assert "User" in repr_str assert "id=1" in repr_str - assert "testuser" in repr_str \ No newline at end of file + 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 index 681a8a7..caf4a74 100644 --- a/backend/users_service/tests/unit/test_schemas.py +++ b/backend/users_service/tests/unit/test_schemas.py @@ -1,4 +1,5 @@ """Unit tests for Pydantic schemas.""" + import pytest from pydantic import ValidationError from app.schemas import UserCreate @@ -32,4 +33,4 @@ def test_user_create_long_username(): def test_user_create_empty_username(): """Test user creation with empty username.""" with pytest.raises(ValidationError): - UserCreate(username="", email="test@example.com") \ No newline at end of file + UserCreate(username="", email="test@example.com") From e60b8cc65dbd8592de937baa0ac975019e9fe104 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 3 Oct 2025 10:14:59 +1000 Subject: [PATCH 4/4] feat(cd-staging): update tests for users service triggered on PR to develop --- .github/workflows/feature_test_users_service.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/feature_test_users_service.yml b/.github/workflows/feature_test_users_service.yml index 22fb4a4..6935012 100644 --- a/.github/workflows/feature_test_users_service.yml +++ b/.github/workflows/feature_test_users_service.yml @@ -1,7 +1,10 @@ name: Feature Branch CI - User Service -# Workflow runs on any changes on Note Services, commited on feature or fix branches on: + # Manual trigger + workflow_dispatch: + + # Workflow runs on any changes on Users Service, commited on feature or fix branches push: branches: - "feature/**" @@ -9,6 +12,11 @@ on: 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: