From c47ade47f4c2643a8867cdc77240231f6cb10cc8 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 2 Oct 2025 15:14:33 +1000 Subject: [PATCH 01/10] feat(notes): add basic crud for notes service --- .gitignore | 197 +++++++++++++++++ backend/notes_service/Dockerfile | 16 ++ backend/notes_service/app/__init__.py | 0 backend/notes_service/app/db.py | 31 +++ backend/notes_service/app/main.py | 235 +++++++++++++++++++++ backend/notes_service/app/models.py | 18 ++ backend/notes_service/app/schemas.py | 23 ++ backend/notes_service/requirements-dev.txt | 10 + backend/notes_service/requirements.txt | 8 + docker-compose.yml | 31 +++ 10 files changed, 569 insertions(+) create mode 100644 .gitignore create mode 100644 backend/notes_service/Dockerfile create mode 100644 backend/notes_service/app/__init__.py create mode 100644 backend/notes_service/app/db.py create mode 100644 backend/notes_service/app/main.py create mode 100644 backend/notes_service/app/models.py create mode 100644 backend/notes_service/app/schemas.py create mode 100644 backend/notes_service/requirements-dev.txt create mode 100644 backend/notes_service/requirements.txt create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfa76a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,197 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# MacOS +.DS_Store + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the enitre vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore \ No newline at end of file diff --git a/backend/notes_service/Dockerfile b/backend/notes_service/Dockerfile new file mode 100644 index 0000000..aa7c4f3 --- /dev/null +++ b/backend/notes_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/notes_service/app/__init__.py b/backend/notes_service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/notes_service/app/db.py b/backend/notes_service/app/db.py new file mode 100644 index 0000000..ef6ae86 --- /dev/null +++ b/backend/notes_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/notes_service/app/main.py b/backend/notes_service/app/main.py new file mode 100644 index 0000000..1e8c235 --- /dev/null +++ b/backend/notes_service/app/main.py @@ -0,0 +1,235 @@ +import logging +import sys, os, time +from typing import List, Optional + +from fastapi import ( + Depends, + FastAPI, + HTTPException, + Query, + Response, + 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 Note +from .schemas import NoteCreate, NoteResponse, NoteUpdate + +# --- 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="Notes Service API", + description="Manages notes for multi-user note-taking application", + version="1.0.0", +) + +# Enable CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Use specific origins in Notesion + 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"Notes Service: Attempting to connect to PostgreSQL and create tables (attempt {i+1}/{max_retries})..." + ) + Base.metadata.create_all(bind=engine) + logger.info( + "Notes Service: Successfully connected to PostgreSQL and ensured tables exist." + ) + break # Exit loop if successful + except OperationalError as e: + logger.warning(f"Notes Service: Failed to connect to PostgreSQL: {e}") + if i < max_retries - 1: + logger.info( + f"Notes Service: Retrying in {retry_delay_seconds} seconds..." + ) + time.sleep(retry_delay_seconds) + else: + logger.critical( + f"Notes 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"Notes 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 Notes Service!"} + + +# --- Health Check Endpoint --- +@app.get("/health", status_code=status.HTTP_200_OK, summary="Health check") +async def health_check(): + return {"status": "ok", "service": "notes-service"} + + +# --- CRUD Endpoints --- +@app.post( + "/notes/", + response_model=NoteResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a new note", +) +async def create_note(note: NoteCreate, db: Session = Depends(get_db)): + """Create a new note""" + logger.info(f"Notes Service: Creating note: {note.title}") + try: + db_note = Note(**note.model_dump()) + db.add(db_note) + db.commit() + db.refresh(db_note) + logger.info(f"Notes Service: Note '{db_note.title}' (ID: {db_note.id}) created successfully.") + return db_note + except Exception as e: + db.rollback() + logger.error(f"Notes Service: Error creating note: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not create note.", + ) + + +@app.get( + "/notes/", + response_model=List[NoteResponse], + summary="Get all notes for a user", +) +def list_notes( + user_id: int = Query(..., description="User ID to fetch notes for"), + db: Session = Depends(get_db), + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), +): + """Retrieve all notes for a specific user""" + logger.info(f"Notes Service: Listing notes for user {user_id}") + notes = ( + db.query(Note) + .filter(Note.user_id == user_id) + .offset(skip) + .limit(limit) + .all() + ) + logger.info(f"Notes Service: Retrieved {len(notes)} notes for user {user_id}") + return notes + + +@app.get( + "/notes/{note_id}", + response_model=NoteResponse, + summary="Get a single note by ID", +) +def get_note(note_id: int, db: Session = Depends(get_db)): + """Retrieve a specific note by ID""" + logger.info(f"Notes Service: Fetching note with ID: {note_id}") + note = db.query(Note).filter(Note.id == note_id).first() + + if not note: + logger.warning(f"Notes Service: Note with ID {note_id} not found.") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Note not found" + ) + + logger.info(f"Notes Service: Retrieved note with ID {note_id}") + return note + + +@app.put( + "/notes/{note_id}", + response_model=NoteResponse, + summary="Update a note by ID", +) +async def update_note( + note_id: int, note: NoteUpdate, db: Session = Depends(get_db) +): + """Update an existing note""" + logger.info(f"Notes Service: Updating note with ID: {note_id}") + db_note = db.query(Note).filter(Note.id == note_id).first() + + if not db_note: + logger.warning(f"Notes Service: Note with ID {note_id} not found for update.") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Note not found" + ) + + update_data = note.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_note, key, value) + + try: + db.add(db_note) + db.commit() + db.refresh(db_note) + logger.info(f"Notes Service: Note {note_id} updated successfully.") + return db_note + except Exception as e: + db.rollback() + logger.error(f"Notes Service: Error updating note {note_id}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not update note.", + ) + + +@app.delete( + "/notes/{note_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a note by ID", +) +def delete_note(note_id: int, db: Session = Depends(get_db)): + """Delete a note""" + logger.info(f"Notes Service: Attempting to delete note with ID: {note_id}") + note = db.query(Note).filter(Note.id == note_id).first() + + if not note: + logger.warning(f"Notes Service: Note with ID {note_id} not found for deletion.") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Note not found" + ) + + try: + db.delete(note) + db.commit() + logger.info(f"Notes Service: Note {note_id} deleted successfully.") + except Exception as e: + db.rollback() + logger.error(f"Notes Service: Error deleting note {note_id}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not delete note.", + ) + + return Response(status_code=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/backend/notes_service/app/models.py b/backend/notes_service/app/models.py new file mode 100644 index 0000000..ce5e6b6 --- /dev/null +++ b/backend/notes_service/app/models.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, DateTime, Integer, String, Text +from sqlalchemy.sql import func + +from .db import Base + + +class Note(Base): + __tablename__ = "notes" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + title = Column(String(255), nullable=False, index=True) + content = Column(Text, nullable=False) + user_id = Column(Integer, nullable=False, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/notes_service/app/schemas.py b/backend/notes_service/app/schemas.py new file mode 100644 index 0000000..3e114b9 --- /dev/null +++ b/backend/notes_service/app/schemas.py @@ -0,0 +1,23 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, ConfigDict, Field + + +class NoteBase(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + content: str = Field(..., min_length=1) + user_id: int = Field(..., gt=0) + +class NoteCreate(NoteBase): + pass + +class NoteUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=255) + content: Optional[str] = Field(None, min_length=1) + +class NoteResponse(NoteBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/notes_service/requirements-dev.txt b/backend/notes_service/requirements-dev.txt new file mode 100644 index 0000000..cac22d9 --- /dev/null +++ b/backend/notes_service/requirements-dev.txt @@ -0,0 +1,10 @@ +fastapi +uvicorn +sqlalchemy +psycopg2-binary +python-multipart +pydantic +azure-storage-blob +aio-pika +pytest +httpx \ No newline at end of file diff --git a/backend/notes_service/requirements.txt b/backend/notes_service/requirements.txt new file mode 100644 index 0000000..e451589 --- /dev/null +++ b/backend/notes_service/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn +sqlalchemy +psycopg2-binary +python-multipart +pydantic +azure-storage-blob +aio-pika diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..10ac0ad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +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 + + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=notes + ports: + - "5432:5432" + volumes: + - notes_db_data:/var/lib/postgresql/data + +# Persistent Volume +volumes: + notes_db_data: \ No newline at end of file From dd4263a18d423c0424affb2d471f0878b5261d39 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 2 Oct 2025 16:59:08 +1000 Subject: [PATCH 02/10] chore(notes): remove redundant import and add endpoint comments --- backend/notes_service/app/main.py | 80 ++++++++++++++-------------- backend/notes_service/app/models.py | 6 +-- backend/notes_service/app/schemas.py | 7 ++- 3 files changed, 49 insertions(+), 44 deletions(-) diff --git a/backend/notes_service/app/main.py b/backend/notes_service/app/main.py index 1e8c235..07e54ad 100644 --- a/backend/notes_service/app/main.py +++ b/backend/notes_service/app/main.py @@ -65,9 +65,7 @@ async def startup_event(): except OperationalError as e: logger.warning(f"Notes Service: Failed to connect to PostgreSQL: {e}") if i < max_retries - 1: - logger.info( - f"Notes Service: Retrying in {retry_delay_seconds} seconds..." - ) + logger.info(f"Notes Service: Retrying in {retry_delay_seconds} seconds...") time.sleep(retry_delay_seconds) else: logger.critical( @@ -95,6 +93,15 @@ async def health_check(): # --- CRUD Endpoints --- +# Create new note +# [POST] http://localhost:8000/notes/ +""" +{ + "title": "Sample Note", + "content": "Sample ID", + "user_id": 1 +} +""" @app.post( "/notes/", response_model=NoteResponse, @@ -109,7 +116,9 @@ async def create_note(note: NoteCreate, db: Session = Depends(get_db)): db.add(db_note) db.commit() db.refresh(db_note) - logger.info(f"Notes Service: Note '{db_note.title}' (ID: {db_note.id}) created successfully.") + logger.info( + f"Notes Service: Note '{db_note.title}' (ID: {db_note.id}) created successfully." + ) return db_note except Exception as e: db.rollback() @@ -119,7 +128,8 @@ async def create_note(note: NoteCreate, db: Session = Depends(get_db)): detail="Could not create note.", ) - +# Get all note for specific user +# [GET] http://localhost:8000/notes/?user_id={user_id} @app.get( "/notes/", response_model=List[NoteResponse], @@ -133,17 +143,12 @@ def list_notes( ): """Retrieve all notes for a specific user""" logger.info(f"Notes Service: Listing notes for user {user_id}") - notes = ( - db.query(Note) - .filter(Note.user_id == user_id) - .offset(skip) - .limit(limit) - .all() - ) + notes = db.query(Note).filter(Note.user_id == user_id).offset(skip).limit(limit).all() logger.info(f"Notes Service: Retrieved {len(notes)} notes for user {user_id}") return notes - +# Get specific note by note_id +# [GET] http://localhost:8000/notes/{note_id} @app.get( "/notes/{note_id}", response_model=NoteResponse, @@ -153,41 +158,40 @@ def get_note(note_id: int, db: Session = Depends(get_db)): """Retrieve a specific note by ID""" logger.info(f"Notes Service: Fetching note with ID: {note_id}") note = db.query(Note).filter(Note.id == note_id).first() - + if not note: logger.warning(f"Notes Service: Note with ID {note_id} not found.") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Note not found" - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found") + logger.info(f"Notes Service: Retrieved note with ID {note_id}") return note - +# Update specific note by note_id +# [PUT] http://localhost:8000/notes/{note_id} +""" +{ + "title": "Sample Note", + "content": "Sample Updated Content" +} +""" @app.put( "/notes/{note_id}", response_model=NoteResponse, summary="Update a note by ID", ) -async def update_note( - note_id: int, note: NoteUpdate, db: Session = Depends(get_db) -): +async def update_note(note_id: int, note: NoteUpdate, db: Session = Depends(get_db)): """Update an existing note""" logger.info(f"Notes Service: Updating note with ID: {note_id}") db_note = db.query(Note).filter(Note.id == note_id).first() - + if not db_note: logger.warning(f"Notes Service: Note with ID {note_id} not found for update.") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Note not found" - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found") + update_data = note.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(db_note, key, value) - + try: db.add(db_note) db.commit() @@ -202,7 +206,8 @@ async def update_note( detail="Could not update note.", ) - +# Delete specific note by note_id +# [DELETE] http://localhost:8000/notes/{note_id} @app.delete( "/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT, @@ -212,14 +217,11 @@ def delete_note(note_id: int, db: Session = Depends(get_db)): """Delete a note""" logger.info(f"Notes Service: Attempting to delete note with ID: {note_id}") note = db.query(Note).filter(Note.id == note_id).first() - + if not note: logger.warning(f"Notes Service: Note with ID {note_id} not found for deletion.") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Note not found" - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found") + try: db.delete(note) db.commit() @@ -231,5 +233,5 @@ def delete_note(note_id: int, db: Session = Depends(get_db)): status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not delete note.", ) - - return Response(status_code=status.HTTP_204_NO_CONTENT) \ No newline at end of file + + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/notes_service/app/models.py b/backend/notes_service/app/models.py index ce5e6b6..8345e28 100644 --- a/backend/notes_service/app/models.py +++ b/backend/notes_service/app/models.py @@ -6,13 +6,13 @@ class Note(Base): __tablename__ = "notes" - + id = Column(Integer, primary_key=True, index=True, autoincrement=True) title = Column(String(255), nullable=False, index=True) content = Column(Text, nullable=False) user_id = Column(Integer, nullable=False, index=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) - + def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/backend/notes_service/app/schemas.py b/backend/notes_service/app/schemas.py index 3e114b9..27c3044 100644 --- a/backend/notes_service/app/schemas.py +++ b/backend/notes_service/app/schemas.py @@ -8,16 +8,19 @@ class NoteBase(BaseModel): content: str = Field(..., min_length=1) user_id: int = Field(..., gt=0) + class NoteCreate(NoteBase): pass + class NoteUpdate(BaseModel): title: Optional[str] = Field(None, min_length=1, max_length=255) content: Optional[str] = Field(None, min_length=1) + class NoteResponse(NoteBase): id: int created_at: datetime updated_at: Optional[datetime] = None - - model_config = ConfigDict(from_attributes=True) \ No newline at end of file + + model_config = ConfigDict(from_attributes=True) From a7974b50a52dc6dbc6a6a26f25ea49fa9d43a06a Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 2 Oct 2025 19:57:56 +1000 Subject: [PATCH 03/10] feat(notes): add automate tests and feature branch CI --- .../_reusable_quality_check_workflow.yml | 49 +++++++++ .github/workflows/_reusable_test_workflow.yml | 77 +++++++++++++ .../workflows/feature_test_notes_service.yml | 23 ++++ backend/notes_service/requirements-dev.txt | 10 +- backend/notes_service/tests/__init__.py | 0 backend/notes_service/tests/conftest.py | 101 ++++++++++++++++++ .../tests/integration/__init__.py | 0 .../tests/integration/test_notes_api.py | 98 +++++++++++++++++ backend/notes_service/tests/unit/__init__.py | 0 .../notes_service/tests/unit/test_models.py | 8 ++ .../notes_service/tests/unit/test_schemas.py | 25 +++++ 11 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/_reusable_quality_check_workflow.yml create mode 100644 .github/workflows/_reusable_test_workflow.yml create mode 100644 .github/workflows/feature_test_notes_service.yml create mode 100644 backend/notes_service/tests/__init__.py create mode 100644 backend/notes_service/tests/conftest.py create mode 100644 backend/notes_service/tests/integration/__init__.py create mode 100644 backend/notes_service/tests/integration/test_notes_api.py create mode 100644 backend/notes_service/tests/unit/__init__.py create mode 100644 backend/notes_service/tests/unit/test_models.py create mode 100644 backend/notes_service/tests/unit/test_schemas.py diff --git a/.github/workflows/_reusable_quality_check_workflow.yml b/.github/workflows/_reusable_quality_check_workflow.yml new file mode 100644 index 0000000..786bc77 --- /dev/null +++ b/.github/workflows/_reusable_quality_check_workflow.yml @@ -0,0 +1,49 @@ +# Reusable quality check: +# - Black: Linting & format code +# - pylint: Code quality +# - bandit: Security linting +name: Reusable Quality Check Workflow + +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + python-version: + required: false + type: string + default: "3.10" + +jobs: + quality-check: + name: Code Quality and Security Check + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install dependencies + working-directory: ${{ inputs.working-directory }} + run: | + pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Format check with Black + working-directory: ${{ inputs.working-directory }} + run: black --check app/ tests/ + + - name: Lint with Pylint + working-directory: ${{ inputs.working-directory }} + run: pylint app/ --fail-under=8.0 + + - name: Security scan with Bandit + working-directory: ${{ inputs.working-directory }} + run: bandit -r app/ -ll \ No newline at end of file diff --git a/.github/workflows/_reusable_test_workflow.yml b/.github/workflows/_reusable_test_workflow.yml new file mode 100644 index 0000000..108439c --- /dev/null +++ b/.github/workflows/_reusable_test_workflow.yml @@ -0,0 +1,77 @@ +# Reusable test workflow +# - pytest: run all defined test files in tests/ +# - pytest-cov: test coverage +name: Reusable Test Workflow + +on: + workflow_call: + inputs: + working-directory: + required: true + type: string + python-version: + required: false + type: string + default: "3.10" + coverage-threshold: + required: false + type: number + default: 80 + +env: + POSTGRES_HOST: localhost + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_db + +jobs: + test: + name: Unit Testing and Code Coverage Check + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install dependencies + working-directory: ${{ inputs.working-directory }} + run: | + pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run tests + working-directory: ${{ inputs.working-directory }} + env: + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + POSTGRES_HOST: ${{ env.POSTGRES_HOST }} + POSTGRES_PORT: 5432 + run: | + pytest tests/ -v --cov=app --cov-report=xml --cov-report=term-missing + + - name: Check coverage + working-directory: ${{ inputs.working-directory }} + run: | + coverage report --fail-under=${{ inputs.coverage-threshold }} \ No newline at end of file diff --git a/.github/workflows/feature_test_notes_service.yml b/.github/workflows/feature_test_notes_service.yml new file mode 100644 index 0000000..dd6a3da --- /dev/null +++ b/.github/workflows/feature_test_notes_service.yml @@ -0,0 +1,23 @@ +name: Feature Branch CI - Note Service + +on: + push: + branches: + - "feature/**" + - "fix/**" + paths: + - "backend/notes_service/**" + +jobs: + quality-checks: + uses: ./.github/workflows/_reusable_quality_check_workflow.yml + secrets: inherit + with: + working-directory: "./backend/notes_service" + + unit-test: + uses: ./.github/workflows/_reusable_test_workflow.yml + secrets: inherit + with: + working-directory: "./backend/notes_service" + coverage-threshold: 80 \ No newline at end of file diff --git a/backend/notes_service/requirements-dev.txt b/backend/notes_service/requirements-dev.txt index cac22d9..c54fcc6 100644 --- a/backend/notes_service/requirements-dev.txt +++ b/backend/notes_service/requirements-dev.txt @@ -6,5 +6,13 @@ python-multipart pydantic azure-storage-blob aio-pika + +# Testing and coverage report pytest -httpx \ No newline at end of file +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/notes_service/tests/__init__.py b/backend/notes_service/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/notes_service/tests/conftest.py b/backend/notes_service/tests/conftest.py new file mode 100644 index 0000000..f39ccd7 --- /dev/null +++ b/backend/notes_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 Note + +# 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"Notes 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( + "Notes 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( + "Notes Service Tests: Successfully created all tables in PostgreSQL for test setup." + ) + break + except OperationalError as e: + logging.warning( + f"Notes 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"Notes 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/notes_service/tests/integration/__init__.py b/backend/notes_service/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/notes_service/tests/integration/test_notes_api.py b/backend/notes_service/tests/integration/test_notes_api.py new file mode 100644 index 0000000..4ce87d3 --- /dev/null +++ b/backend/notes_service/tests/integration/test_notes_api.py @@ -0,0 +1,98 @@ +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +def test_read_root(client: TestClient): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "Welcome to the Notes Service!"} + + +def test_health_check(client: TestClient): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "service": "notes-service"} + + +def test_create_note_success(client: TestClient, db_session_for_test: Session): + test_data = {"title": "Test Note", "content": "Test content", "user_id": 1} + response = client.post("/notes/", json=test_data) + + assert response.status_code == 201 + data = response.json() + assert data["title"] == test_data["title"] + assert data["content"] == test_data["content"] + assert data["user_id"] == test_data["user_id"] + assert "id" in data + assert "created_at" in data + + +def test_create_note_invalid_user_id(client: TestClient): + invalid_data = {"title": "Invalid Note", "content": "Content", "user_id": -1} # Invalid user_id + response = client.post("/notes/", json=invalid_data) + assert response.status_code == 422 + + +def test_list_notes_empty(client: TestClient): + response = client.get("/notes/?user_id=999") + assert response.status_code == 200 + assert response.json() == [] + + +def test_list_notes_with_data(client: TestClient, db_session_for_test: Session): + # Create note + note_data = {"title": "List Test", "content": "Content", "user_id": 1} + client.post("/notes/", json=note_data) + + # List notes + response = client.get("/notes/?user_id=1") + assert response.status_code == 200 + assert len(response.json()) >= 1 + assert any(n["title"] == "List Test" for n in response.json()) + + +def test_get_note_success(client: TestClient, db_session_for_test: Session): + # Create note + create_response = client.post( + "/notes/", json={"title": "Get Test", "content": "Content", "user_id": 1} + ) + note_id = create_response.json()["id"] + + # Get note + response = client.get(f"/notes/{note_id}") + assert response.status_code == 200 + assert response.json()["id"] == note_id + + +def test_get_note_not_found(client: TestClient): + response = client.get("/notes/99999") + assert response.status_code == 404 + + +def test_update_note_partial(client: TestClient, db_session_for_test: Session): + # Create note + create_resp = client.post( + "/notes/", json={"title": "Original", "content": "Original content", "user_id": 1} + ) + note_id = create_resp.json()["id"] + + # Update + update_data = {"title": "Updated Title"} + response = client.put(f"/notes/{note_id}", json=update_data) + assert response.status_code == 200 + assert response.json()["title"] == "Updated Title" + + +def test_delete_note_success(client: TestClient, db_session_for_test: Session): + # Create note + create_resp = client.post( + "/notes/", json={"title": "Delete Me", "content": "Content", "user_id": 1} + ) + note_id = create_resp.json()["id"] + + # Delete + response = client.delete(f"/notes/{note_id}") + assert response.status_code == 204 + + # Verify deletion + get_response = client.get(f"/notes/{note_id}") + assert get_response.status_code == 404 diff --git a/backend/notes_service/tests/unit/__init__.py b/backend/notes_service/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/notes_service/tests/unit/test_models.py b/backend/notes_service/tests/unit/test_models.py new file mode 100644 index 0000000..1408d36 --- /dev/null +++ b/backend/notes_service/tests/unit/test_models.py @@ -0,0 +1,8 @@ +from app.models import Note + + +def test_note_repr(): + note = Note(id=1, title="Test", content="Content", user_id=1) + repr_str = repr(note) + assert "Note" in repr_str + assert "id=1" in repr_str diff --git a/backend/notes_service/tests/unit/test_schemas.py b/backend/notes_service/tests/unit/test_schemas.py new file mode 100644 index 0000000..46c8281 --- /dev/null +++ b/backend/notes_service/tests/unit/test_schemas.py @@ -0,0 +1,25 @@ +import pytest +from pydantic import ValidationError +from app.schemas import NoteCreate, NoteUpdate + + +def test_note_create_valid(): + note = NoteCreate(title="Test", content="Content", user_id=1) + assert note.title == "Test" + assert note.user_id == 1 + + +def test_note_create_invalid_user_id(): + with pytest.raises(ValidationError): + NoteCreate(title="Test", content="Content", user_id=-1) + + +def test_note_create_empty_title(): + with pytest.raises(ValidationError): + NoteCreate(title="", content="Content", user_id=1) + + +def test_note_update_partial(): + update = NoteUpdate(title="New Title") + assert update.title == "New Title" + assert update.content is None From c6d220aa5348383d7fddcdb7fa1542929f679cab Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 2 Oct 2025 20:09:13 +1000 Subject: [PATCH 04/10] fix(notes): reformat code to pass the linting check --- .../_reusable_quality_check_workflow.yml | 9 +++-- .github/workflows/_reusable_test_workflow.yml | 2 +- .../workflows/feature_test_notes_service.yml | 4 ++- backend/notes_service/app/main.py | 36 +++++++++++++++---- .../tests/integration/test_notes_api.py | 10 ++++-- 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/.github/workflows/_reusable_quality_check_workflow.yml b/.github/workflows/_reusable_quality_check_workflow.yml index 786bc77..4351808 100644 --- a/.github/workflows/_reusable_quality_check_workflow.yml +++ b/.github/workflows/_reusable_quality_check_workflow.yml @@ -38,12 +38,15 @@ jobs: - name: Format check with Black working-directory: ${{ inputs.working-directory }} - run: black --check app/ tests/ + run: | + black --check app/ tests/ - name: Lint with Pylint working-directory: ${{ inputs.working-directory }} - run: pylint app/ --fail-under=8.0 + run: | + pylint app/ --fail-under=8.0 - name: Security scan with Bandit working-directory: ${{ inputs.working-directory }} - run: bandit -r app/ -ll \ No newline at end of file + run: | + bandit -r app/ -ll \ No newline at end of file diff --git a/.github/workflows/_reusable_test_workflow.yml b/.github/workflows/_reusable_test_workflow.yml index 108439c..dd8f24e 100644 --- a/.github/workflows/_reusable_test_workflow.yml +++ b/.github/workflows/_reusable_test_workflow.yml @@ -26,7 +26,7 @@ env: jobs: test: - name: Unit Testing and Code Coverage Check + name: Testing and Code Coverage Check runs-on: ubuntu-latest services: diff --git a/.github/workflows/feature_test_notes_service.yml b/.github/workflows/feature_test_notes_service.yml index dd6a3da..089fc30 100644 --- a/.github/workflows/feature_test_notes_service.yml +++ b/.github/workflows/feature_test_notes_service.yml @@ -10,12 +10,14 @@ on: jobs: quality-checks: + name: Quality Check for Notes Service uses: ./.github/workflows/_reusable_quality_check_workflow.yml secrets: inherit with: working-directory: "./backend/notes_service" - unit-test: + test: + name: Run Tests for Notes Service uses: ./.github/workflows/_reusable_test_workflow.yml secrets: inherit with: diff --git a/backend/notes_service/app/main.py b/backend/notes_service/app/main.py index 07e54ad..69da55e 100644 --- a/backend/notes_service/app/main.py +++ b/backend/notes_service/app/main.py @@ -65,7 +65,9 @@ async def startup_event(): except OperationalError as e: logger.warning(f"Notes Service: Failed to connect to PostgreSQL: {e}") if i < max_retries - 1: - logger.info(f"Notes Service: Retrying in {retry_delay_seconds} seconds...") + logger.info( + f"Notes Service: Retrying in {retry_delay_seconds} seconds..." + ) time.sleep(retry_delay_seconds) else: logger.critical( @@ -102,6 +104,8 @@ async def health_check(): "user_id": 1 } """ + + @app.post( "/notes/", response_model=NoteResponse, @@ -128,6 +132,7 @@ async def create_note(note: NoteCreate, db: Session = Depends(get_db)): detail="Could not create note.", ) + # Get all note for specific user # [GET] http://localhost:8000/notes/?user_id={user_id} @app.get( @@ -143,10 +148,13 @@ def list_notes( ): """Retrieve all notes for a specific user""" logger.info(f"Notes Service: Listing notes for user {user_id}") - notes = db.query(Note).filter(Note.user_id == user_id).offset(skip).limit(limit).all() + notes = ( + db.query(Note).filter(Note.user_id == user_id).offset(skip).limit(limit).all() + ) logger.info(f"Notes Service: Retrieved {len(notes)} notes for user {user_id}") return notes + # Get specific note by note_id # [GET] http://localhost:8000/notes/{note_id} @app.get( @@ -161,11 +169,14 @@ def get_note(note_id: int, db: Session = Depends(get_db)): if not note: logger.warning(f"Notes Service: Note with ID {note_id} not found.") - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Note not found" + ) logger.info(f"Notes Service: Retrieved note with ID {note_id}") return note + # Update specific note by note_id # [PUT] http://localhost:8000/notes/{note_id} """ @@ -174,6 +185,8 @@ def get_note(note_id: int, db: Session = Depends(get_db)): "content": "Sample Updated Content" } """ + + @app.put( "/notes/{note_id}", response_model=NoteResponse, @@ -186,7 +199,9 @@ async def update_note(note_id: int, note: NoteUpdate, db: Session = Depends(get_ if not db_note: logger.warning(f"Notes Service: Note with ID {note_id} not found for update.") - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Note not found" + ) update_data = note.model_dump(exclude_unset=True) for key, value in update_data.items(): @@ -200,12 +215,15 @@ async def update_note(note_id: int, note: NoteUpdate, db: Session = Depends(get_ return db_note except Exception as e: db.rollback() - logger.error(f"Notes Service: Error updating note {note_id}: {e}", exc_info=True) + logger.error( + f"Notes Service: Error updating note {note_id}: {e}", exc_info=True + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not update note.", ) + # Delete specific note by note_id # [DELETE] http://localhost:8000/notes/{note_id} @app.delete( @@ -220,7 +238,9 @@ def delete_note(note_id: int, db: Session = Depends(get_db)): if not note: logger.warning(f"Notes Service: Note with ID {note_id} not found for deletion.") - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Note not found" + ) try: db.delete(note) @@ -228,7 +248,9 @@ def delete_note(note_id: int, db: Session = Depends(get_db)): logger.info(f"Notes Service: Note {note_id} deleted successfully.") except Exception as e: db.rollback() - logger.error(f"Notes Service: Error deleting note {note_id}: {e}", exc_info=True) + logger.error( + f"Notes Service: Error deleting note {note_id}: {e}", exc_info=True + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not delete note.", diff --git a/backend/notes_service/tests/integration/test_notes_api.py b/backend/notes_service/tests/integration/test_notes_api.py index 4ce87d3..c47f733 100644 --- a/backend/notes_service/tests/integration/test_notes_api.py +++ b/backend/notes_service/tests/integration/test_notes_api.py @@ -1,6 +1,7 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session + def test_read_root(client: TestClient): response = client.get("/") assert response.status_code == 200 @@ -27,7 +28,11 @@ def test_create_note_success(client: TestClient, db_session_for_test: Session): def test_create_note_invalid_user_id(client: TestClient): - invalid_data = {"title": "Invalid Note", "content": "Content", "user_id": -1} # Invalid user_id + invalid_data = { + "title": "Invalid Note", + "content": "Content", + "user_id": -1, + } # Invalid user_id response = client.post("/notes/", json=invalid_data) assert response.status_code == 422 @@ -71,7 +76,8 @@ def test_get_note_not_found(client: TestClient): def test_update_note_partial(client: TestClient, db_session_for_test: Session): # Create note create_resp = client.post( - "/notes/", json={"title": "Original", "content": "Original content", "user_id": 1} + "/notes/", + json={"title": "Original", "content": "Original content", "user_id": 1}, ) note_id = create_resp.json()["id"] From ce79c67be292a60def1caa872fed0e3f7e19eaa8 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 2 Oct 2025 20:33:56 +1000 Subject: [PATCH 05/10] fix(notes): update code to pass the pylint check --- .github/workflows/_reusable_quality_check_workflow.yml | 9 +++++++-- .github/workflows/_reusable_test_workflow.yml | 1 + .github/workflows/feature_test_notes_service.yml | 2 ++ backend/notes_service/app/main.py | 5 +++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/_reusable_quality_check_workflow.yml b/.github/workflows/_reusable_quality_check_workflow.yml index 4351808..5a02ffc 100644 --- a/.github/workflows/_reusable_quality_check_workflow.yml +++ b/.github/workflows/_reusable_quality_check_workflow.yml @@ -1,9 +1,10 @@ # Reusable quality check: -# - Black: Linting & format code +# - black: Code format # - pylint: Code quality # - bandit: Security linting name: Reusable Quality Check Workflow +# Workflow runs on being called by others on: workflow_call: inputs: @@ -14,6 +15,10 @@ on: required: false type: string default: "3.10" + linting-threshold: + required: false + type: number + default: 8.0 jobs: quality-check: @@ -44,7 +49,7 @@ jobs: - name: Lint with Pylint working-directory: ${{ inputs.working-directory }} run: | - pylint app/ --fail-under=8.0 + pylint app/ --fail-under=${{ inputs.linting-threshold }} - name: Security scan with Bandit working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/_reusable_test_workflow.yml b/.github/workflows/_reusable_test_workflow.yml index dd8f24e..ab6bb7b 100644 --- a/.github/workflows/_reusable_test_workflow.yml +++ b/.github/workflows/_reusable_test_workflow.yml @@ -3,6 +3,7 @@ # - pytest-cov: test coverage name: Reusable Test Workflow +# Workflow runs on being called by others on: workflow_call: inputs: diff --git a/.github/workflows/feature_test_notes_service.yml b/.github/workflows/feature_test_notes_service.yml index 089fc30..afcf63e 100644 --- a/.github/workflows/feature_test_notes_service.yml +++ b/.github/workflows/feature_test_notes_service.yml @@ -1,5 +1,6 @@ name: Feature Branch CI - Note Service +# Workflow runs on any changes on Note Services, commited on feature or fix branches on: push: branches: @@ -15,6 +16,7 @@ jobs: secrets: inherit with: working-directory: "./backend/notes_service" + linting-threshold: 6.0 test: name: Run Tests for Notes Service diff --git a/backend/notes_service/app/main.py b/backend/notes_service/app/main.py index 69da55e..431a88b 100644 --- a/backend/notes_service/app/main.py +++ b/backend/notes_service/app/main.py @@ -1,6 +1,7 @@ import logging -import sys, os, time -from typing import List, Optional +import sys +import time +from typing import List from fastapi import ( Depends, From 349c526db169f5225635ee7104da3c8992888735 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 2 Oct 2025 20:55:33 +1000 Subject: [PATCH 06/10] chore(notes): update code for higher pylint score --- backend/notes_service/.pylintrc | 2 ++ backend/notes_service/app/db.py | 2 ++ backend/notes_service/app/main.py | 24 +++++++++--------------- backend/notes_service/app/models.py | 4 ++++ backend/notes_service/app/schemas.py | 10 ++++++++++ 5 files changed, 27 insertions(+), 15 deletions(-) create mode 100644 backend/notes_service/.pylintrc diff --git a/backend/notes_service/.pylintrc b/backend/notes_service/.pylintrc new file mode 100644 index 0000000..1f402fc --- /dev/null +++ b/backend/notes_service/.pylintrc @@ -0,0 +1,2 @@ +[MESSAGES CONTROL] +disable=logging-fstring-interpolation \ No newline at end of file diff --git a/backend/notes_service/app/db.py b/backend/notes_service/app/db.py index ef6ae86..ca48eb8 100644 --- a/backend/notes_service/app/db.py +++ b/backend/notes_service/app/db.py @@ -1,3 +1,5 @@ +"""Database configuration and session management.""" + import os from sqlalchemy import create_engine diff --git a/backend/notes_service/app/main.py b/backend/notes_service/app/main.py index 431a88b..fa5e201 100644 --- a/backend/notes_service/app/main.py +++ b/backend/notes_service/app/main.py @@ -1,3 +1,9 @@ +""" +Notes Service API. + +FastAPI application for managing notes in a multi-user note-taking platform. +""" + import logging import sys import time @@ -51,6 +57,7 @@ # --- Startup Event --- @app.on_event("startup") async def startup_event(): + """Initialize database connection on application startup.""" max_retries = 10 retry_delay_seconds = 5 for i in range(max_retries): @@ -86,27 +93,20 @@ async def startup_event(): # --- Root Endpoint --- @app.get("/", status_code=status.HTTP_200_OK, summary="Root endpoint") async def read_root(): + """Return welcome message.""" return {"message": "Welcome to the Notes Service!"} # --- Health Check Endpoint --- @app.get("/health", status_code=status.HTTP_200_OK, summary="Health check") async def health_check(): + """Health check endpoint for monitoring.""" return {"status": "ok", "service": "notes-service"} # --- CRUD Endpoints --- # Create new note # [POST] http://localhost:8000/notes/ -""" -{ - "title": "Sample Note", - "content": "Sample ID", - "user_id": 1 -} -""" - - @app.post( "/notes/", response_model=NoteResponse, @@ -180,12 +180,6 @@ def get_note(note_id: int, db: Session = Depends(get_db)): # Update specific note by note_id # [PUT] http://localhost:8000/notes/{note_id} -""" -{ - "title": "Sample Note", - "content": "Sample Updated Content" -} -""" @app.put( diff --git a/backend/notes_service/app/models.py b/backend/notes_service/app/models.py index 8345e28..b43543d 100644 --- a/backend/notes_service/app/models.py +++ b/backend/notes_service/app/models.py @@ -1,3 +1,5 @@ +"""SQLAlchemy database models.""" + from sqlalchemy import Column, DateTime, Integer, String, Text from sqlalchemy.sql import func @@ -5,6 +7,8 @@ class Note(Base): + """Note model for storing user notes.""" + __tablename__ = "notes" id = Column(Integer, primary_key=True, index=True, autoincrement=True) diff --git a/backend/notes_service/app/schemas.py b/backend/notes_service/app/schemas.py index 27c3044..4bd22b2 100644 --- a/backend/notes_service/app/schemas.py +++ b/backend/notes_service/app/schemas.py @@ -1,24 +1,34 @@ +"""Pydantic schemas for request/response validation.""" + from datetime import datetime from typing import Optional from pydantic import BaseModel, ConfigDict, Field class NoteBase(BaseModel): + """Base note schema with common fields.""" + title: str = Field(..., min_length=1, max_length=255) content: str = Field(..., min_length=1) user_id: int = Field(..., gt=0) class NoteCreate(NoteBase): + """Schema for creating a new note.""" + pass class NoteUpdate(BaseModel): + """Schema for updating an existing note.""" + title: Optional[str] = Field(None, min_length=1, max_length=255) content: Optional[str] = Field(None, min_length=1) class NoteResponse(NoteBase): + """Schema for note response.""" + id: int created_at: datetime updated_at: Optional[datetime] = None From e790ab861c264c4f2f6a491686a1c34b7e9b4822 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 3 Oct 2025 01:15:35 +1000 Subject: [PATCH 07/10] feat(notes): separate test jobs in reusable test for clarity --- .github/workflows/_reusable_test_workflow.yml | 91 ++++++++++++++++++- .../workflows/feature_test_notes_service.yml | 2 +- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/.github/workflows/_reusable_test_workflow.yml b/.github/workflows/_reusable_test_workflow.yml index ab6bb7b..ee08d9a 100644 --- a/.github/workflows/_reusable_test_workflow.yml +++ b/.github/workflows/_reusable_test_workflow.yml @@ -26,10 +26,57 @@ env: POSTGRES_DB: test_db jobs: - test: - name: Testing and Code Coverage Check + unit-test: + name: Run Unit Testing (schemas, basic logic) runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install dependencies + working-directory: ${{ inputs.working-directory }} + run: | + pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run unit tests + working-directory: ${{ inputs.working-directory }} + run: | + pytest tests/unit/ -v --cov=app --cov-report=xml --cov-report=term-missing + + - name: Upload unit test coverage + uses: actions/upload-artifact@v4 + with: + name: unit-coverage + path: ${{ inputs.working-directory }}/coverage.xml + + integration-test: + name: Run Integration Testing (API + database) + runs-on: ubuntu-latest + needs: unit-test + services: postgres: image: postgres:15 @@ -70,9 +117,45 @@ jobs: POSTGRES_HOST: ${{ env.POSTGRES_HOST }} POSTGRES_PORT: 5432 run: | - pytest tests/ -v --cov=app --cov-report=xml --cov-report=term-missing + pytest tests/integration/ -v --cov=app --cov-report=xml --cov-report=term-missing + + - name: Upload integration coverage + uses: actions/upload-artifact@v4 + with: + name: integration-coverage + path: ${{ inputs.working-directory }}/coverage.xml + + coverage-report: + name: Combined Coverage Check + runs-on: ubuntu-latest + needs: [unit-test, integration-test] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 - - name: Check coverage + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install coverage + run: pip install coverage + + - name: Download unit coverage + uses: actions/download-artifact@v4 + with: + name: unit-coverage + path: ./coverage-unit + + - name: Download integration coverage + uses: actions/download-artifact@v4 + with: + name: integration-coverage + path: ./coverage-integration + + - name: Check combined coverage working-directory: ${{ inputs.working-directory }} run: | + coverage combine ../coverage-unit/.coverage ../coverage-integration/.coverage coverage report --fail-under=${{ inputs.coverage-threshold }} \ No newline at end of file diff --git a/.github/workflows/feature_test_notes_service.yml b/.github/workflows/feature_test_notes_service.yml index afcf63e..a3ef286 100644 --- a/.github/workflows/feature_test_notes_service.yml +++ b/.github/workflows/feature_test_notes_service.yml @@ -16,7 +16,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 From b8b88f9157251368d332ed933b26477df58f0d6a Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 3 Oct 2025 01:19:11 +1000 Subject: [PATCH 08/10] fix(notes): update trigger events on note service CI --- .github/workflows/feature_test_notes_service.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/feature_test_notes_service.yml b/.github/workflows/feature_test_notes_service.yml index a3ef286..2f59c2e 100644 --- a/.github/workflows/feature_test_notes_service.yml +++ b/.github/workflows/feature_test_notes_service.yml @@ -2,12 +2,15 @@ name: Feature Branch CI - Note Service # Workflow runs on any changes on Note Services, commited on feature or fix branches on: + workflow_dispatch: + push: branches: - "feature/**" - "fix/**" paths: - "backend/notes_service/**" + - ".github/workflows/*notes_service*.yml" jobs: quality-checks: From 702ac3f2d5e04c35d1abd2378d8f7770b9103be2 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 3 Oct 2025 08:42:56 +1000 Subject: [PATCH 09/10] fix(notes): fix runtime error issue of reusable test --- .github/workflows/_reusable_test_workflow.yml | 46 +------------------ 1 file changed, 2 insertions(+), 44 deletions(-) diff --git a/.github/workflows/_reusable_test_workflow.yml b/.github/workflows/_reusable_test_workflow.yml index ee08d9a..3ea6032 100644 --- a/.github/workflows/_reusable_test_workflow.yml +++ b/.github/workflows/_reusable_test_workflow.yml @@ -64,13 +64,7 @@ jobs: - name: Run unit tests working-directory: ${{ inputs.working-directory }} run: | - pytest tests/unit/ -v --cov=app --cov-report=xml --cov-report=term-missing - - - name: Upload unit test coverage - uses: actions/upload-artifact@v4 - with: - name: unit-coverage - path: ${{ inputs.working-directory }}/coverage.xml + pytest tests/unit/ -v integration-test: name: Run Integration Testing (API + database) @@ -119,43 +113,7 @@ jobs: run: | pytest tests/integration/ -v --cov=app --cov-report=xml --cov-report=term-missing - - name: Upload integration coverage - uses: actions/upload-artifact@v4 - with: - name: integration-coverage - path: ${{ inputs.working-directory }}/coverage.xml - - coverage-report: - name: Combined Coverage Check - runs-on: ubuntu-latest - needs: [unit-test, integration-test] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python-version }} - - - name: Install coverage - run: pip install coverage - - - name: Download unit coverage - uses: actions/download-artifact@v4 - with: - name: unit-coverage - path: ./coverage-unit - - - name: Download integration coverage - uses: actions/download-artifact@v4 - with: - name: integration-coverage - path: ./coverage-integration - - - name: Check combined coverage + - name: Check coverage working-directory: ${{ inputs.working-directory }} run: | - coverage combine ../coverage-unit/.coverage ../coverage-integration/.coverage coverage report --fail-under=${{ inputs.coverage-threshold }} \ No newline at end of file From 9f0cd3541c5021b4fe32b46b6c49224d5eeb9143 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 3 Oct 2025 10:03:11 +1000 Subject: [PATCH 10/10] feat(cd-staging): update tests for notes service triggered on PR to develop --- .github/workflows/feature_test_notes_service.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/feature_test_notes_service.yml b/.github/workflows/feature_test_notes_service.yml index 2f59c2e..80991c2 100644 --- a/.github/workflows/feature_test_notes_service.yml +++ b/.github/workflows/feature_test_notes_service.yml @@ -1,9 +1,10 @@ name: Feature Branch CI - Note 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 Note Services, commited on feature or fix branches push: branches: - "feature/**" @@ -11,6 +12,11 @@ on: paths: - "backend/notes_service/**" - ".github/workflows/*notes_service*.yml" + + # Re-run the test when the new PR to develop is created + pull_request: + branches: + - "develop" jobs: quality-checks: