diff --git a/services/content-service/Dockerfile b/services/content-service/Dockerfile index e929c86..0cd671d 100644 --- a/services/content-service/Dockerfile +++ b/services/content-service/Dockerfile @@ -10,14 +10,22 @@ WORKDIR /app # Change ownership of the working directory to the non-root user RUN chown -R appuser /app +# Set PYTHONPATH to ensure app is included in the path +ENV PYTHONPATH=/app + # Copy only requirements.txt and install dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy only the necessary application code +# Copy application code COPY app/ ./app + +# Copy Alembic directory and related files COPY alembic/ ./alembic -COPY alembic.ini . +COPY alembic.ini ./ + +# Copy tests directory (optional, if you want to run tests in the container) +COPY tests/ ./tests # Expose the port for content-service (8000) EXPOSE 8000 @@ -25,5 +33,5 @@ EXPOSE 8000 # Switch to the non-root user USER appuser -# Run the app using uvicorn -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run Alembic migrations before starting the app +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/services/content-service/app/conftest.py b/services/content-service/app/conftest.py deleted file mode 100644 index 386d24a..0000000 --- a/services/content-service/app/conftest.py +++ /dev/null @@ -1,31 +0,0 @@ -import pytest -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from app.db import Base, get_test_db -from fastapi.testclient import TestClient -from app.main import app - -# 테스트용 데이터베이스 설정 -TEST_DATABASE_URL = "postgresql://admin:1234@content-db:5432/test_content_db" -test_engine = create_engine(TEST_DATABASE_URL) -TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine) - -# pytest fixture: 테스트가 실행되기 전에 DB를 초기화하고, 테스트가 끝나면 데이터를 정리 -@pytest.fixture(scope="module") -def test_db(): - Base.metadata.create_all(bind=test_engine) # 테이블 생성 - yield TestSessionLocal() # 테스트 실행 - Base.metadata.drop_all(bind=test_engine) # 테스트가 끝나면 테이블 제거 - -@pytest.fixture(scope="module") -def client(test_db): - # 테스트용 DB로 연결되도록 의존성을 덮어씌우기 - def _get_test_db_override(): - try: - yield test_db - finally: - test_db.close() - - app.dependency_overrides[get_test_db] = _get_test_db_override - with TestClient(app) as c: - yield c diff --git a/services/content-service/app/content.py b/services/content-service/app/content.py index 1dc4e60..6f16f10 100644 --- a/services/content-service/app/content.py +++ b/services/content-service/app/content.py @@ -1,12 +1,17 @@ +# content.py +import json from fastapi import APIRouter, HTTPException, Depends from sqlalchemy.orm import Session from app.schemas import ContentCreate, Content as ContentSchema from app.models import Content from app.db import get_db +import redis router = APIRouter() -# 콘텐츠 생성 엔드포인트 +redis_client = redis.Redis(host='redis', port=6379, db=0, decode_responses=True) +CACHE_TTL = 3600 + @router.post("/", response_model=ContentSchema, status_code=201) def create_content(content: ContentCreate, db: Session = Depends(get_db)): new_content = Content( @@ -18,26 +23,39 @@ def create_content(content: ContentCreate, db: Session = Depends(get_db)): db.add(new_content) db.commit() db.refresh(new_content) + + redis_client.delete("content_list") # 캐시 삭제 + return new_content -# 콘텐츠 목록 조회 엔드포인트 (필터링 기능 추가) @router.get("/", response_model=list[ContentSchema]) def get_contents(title: str = None, category: str = None, db: Session = Depends(get_db)): - query = db.query(Content) + redis_key = f"content_list:{title}:{category}" + + cached_data = redis_client.get(redis_key) + if cached_data: + return json.loads(cached_data) + query = db.query(Content) if title: query = query.filter(Content.title.ilike(f"%{title}%")) if category: query = query.filter(Content.category == category) contents = query.all() + + # 헬퍼 메서드를 사용하여 데이터를 JSON으로 변환 + redis_client.setex(redis_key, CACHE_TTL, json.dumps([content.to_dict() for content in contents])) + return contents -# 콘텐츠 삭제 엔드포인트 @router.delete("/{content_id}", status_code=204) def delete_content(content_id: int, db: Session = Depends(get_db)): content = db.query(Content).filter(Content.id == content_id).first() if content is None: raise HTTPException(status_code=404, detail="Content not found") + db.delete(content) db.commit() + + redis_client.delete("content_list") # 캐시 삭제 diff --git a/services/content-service/app/main.py b/services/content-service/app/main.py index 72f4712..ff09528 100644 --- a/services/content-service/app/main.py +++ b/services/content-service/app/main.py @@ -1,9 +1,13 @@ from fastapi import FastAPI from app.content import router as content_router from app.community import router as community_router +import redis app = FastAPI() +# Redis 클라이언트 초기화 +redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True) + # 콘텐츠 서비스 라우터 추가 app.include_router(content_router, prefix="/contents", tags=["contents"]) diff --git a/services/content-service/app/models.py b/services/content-service/app/models.py index 513d405..1c53bbe 100644 --- a/services/content-service/app/models.py +++ b/services/content-service/app/models.py @@ -1,9 +1,7 @@ -# models.py from sqlalchemy import Column, Integer, String, Text, Index, ForeignKey from sqlalchemy.orm import relationship from app.db import Base - # 콘텐츠 정보를 저장할 데이터베이스 모델 정의 class Content(Base): __tablename__ = "contents" @@ -14,6 +12,16 @@ class Content(Base): category = Column(String, nullable=True, index=True) # 인덱스 추가 creator = Column(String, nullable=True, index=True) # 인덱스 추가 + # 콘텐츠를 딕셔너리로 변환하는 메서드 추가 + def to_dict(self): + return { + "id": self.id, + "title": self.title, + "description": self.description, + "category": self.category, + "creator": self.creator + } + # 복합 인덱스 추가 Index('idx_title_category', Content.title, Content.category) @@ -28,7 +36,6 @@ class Post(Base): # 댓글 관계 설정 comments = relationship("Comment", back_populates="post", cascade="all, delete-orphan") - class Comment(Base): __tablename__ = "comments" @@ -38,4 +45,4 @@ class Comment(Base): post_id = Column(Integer, ForeignKey("posts.id")) # 게시물과의 관계 설정 - post = relationship("Post", back_populates="comments") \ No newline at end of file + post = relationship("Post", back_populates="comments") diff --git a/services/content-service/requirements.txt b/services/content-service/requirements.txt index cf06d88..953e31c 100644 --- a/services/content-service/requirements.txt +++ b/services/content-service/requirements.txt @@ -1,6 +1,7 @@ alembic==1.13.3 annotated-types==0.7.0 anyio==4.6.0 +async-timeout==4.0.3 certifi==2024.8.30 charset-normalizer==3.3.2 click==8.1.7 @@ -20,6 +21,7 @@ pydantic==2.9.2 pydantic_core==2.23.4 pytest==8.3.3 python-dotenv==1.0.1 +redis==5.1.1 requests==2.32.3 sniffio==1.3.1 SQLAlchemy==2.0.32 diff --git a/services/content-service/tests/conftest.py b/services/content-service/tests/conftest.py index 56fb40a..924ce96 100644 --- a/services/content-service/tests/conftest.py +++ b/services/content-service/tests/conftest.py @@ -1,5 +1,36 @@ import sys import os +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.db import Base, get_test_db +from fastapi.testclient import TestClient +from app.main import app # 프로젝트의 루트 경로를 PYTHONPATH에 추가 -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) \ No newline at end of file +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) + +# 테스트용 데이터베이스 설정 +TEST_DATABASE_URL = "postgresql://admin:1234@content-db:5432/test_content_db" +test_engine = create_engine(TEST_DATABASE_URL) +TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine) + +# pytest fixture: 테스트가 실행되기 전에 DB를 초기화하고, 테스트가 끝나면 데이터를 정리 +@pytest.fixture(scope="module") +def test_db(): + Base.metadata.create_all(bind=test_engine) # 테이블 생성 + yield TestSessionLocal() # 테스트 실행 + Base.metadata.drop_all(bind=test_engine) # 테스트가 끝나면 테이블 제거 + +@pytest.fixture(scope="module") +def client(test_db): + # 테스트용 DB로 연결되도록 의존성을 덮어씌우기 + def _get_test_db_override(): + try: + yield test_db + finally: + test_db.close() + + app.dependency_overrides[get_test_db] = _get_test_db_override + with TestClient(app) as c: + yield c diff --git a/services/content-service/tests/test_community.py b/services/content-service/tests/test_community.py index 2ce62ee..03c6862 100644 --- a/services/content-service/tests/test_community.py +++ b/services/content-service/tests/test_community.py @@ -1,7 +1,7 @@ +import time +import requests from fastapi.testclient import TestClient from app.main import app -import requests -import time client = TestClient(app) @@ -9,52 +9,35 @@ def get_user_info(author_id: int): try: response = requests.get(f"https://user-service:8001/api/users/{author_id}") - response.raise_for_status() # 상태 코드가 200이 아닐 경우 HTTPError 발생 + response.raise_for_status() return response.json() - except requests.exceptions.HTTPError as http_err: - raise requests.exceptions.HTTPError(f"HTTP error occurred while fetching user info: {http_err}") - except requests.exceptions.ConnectionError as conn_err: - raise requests.exceptions.ConnectionError(f"Connection error occurred: {conn_err}") - except requests.exceptions.Timeout as timeout_err: - raise requests.exceptions.Timeout(f"Timeout occurred: {timeout_err}") - except requests.exceptions.RequestException as req_err: - raise requests.exceptions.RequestException(f"Unexpected error occurred: {req_err}") + except requests.exceptions.RequestException as e: + raise e # 게시물 생성 테스트 def test_create_post(): - try: - user_info = get_user_info(1) # 유저 서비스에서 유저 정보 조회 - except requests.exceptions.RequestException as e: - assert False, f"유저 정보 조회 실패: {e}" - + user_info = get_user_info(1) # 유저 서비스에서 유저 정보 조회 response = client.post("/posts/", json={ "title": "Test Post", "content": "This is a test post", "author_id": user_info["id"] }) - assert response.status_code == 201, f"게시물 생성 실패: {response.json()}" + assert response.status_code == 201 assert response.json()["title"] == "Test Post" # 댓글 추가 테스트 def test_add_comment(): - try: - user_info = get_user_info(1) - except requests.exceptions.RequestException as e: - assert False, f"유저 정보 조회 실패: {e}" - + user_info = get_user_info(1) response = client.post("/posts/1/comments", json={ "content": "This is a test comment", "author_id": user_info["id"] }) - assert response.status_code == 201, f"댓글 생성 실패: {response.json()}" + assert response.status_code == 201 assert response.json()["content"] == "This is a test comment" # 게시물 삭제 테스트 def test_delete_post(): - try: - user_info = get_user_info(1) - except requests.exceptions.RequestException as e: - assert False, f"유저 정보 조회 실패: {e}" + user_info = get_user_info(1) # 게시물 생성 response = client.post("/posts/", json={ @@ -62,26 +45,22 @@ def test_delete_post(): "content": "This post will be deleted", "author_id": user_info["id"] }) - assert response.status_code == 201, f"게시물 생성 실패: {response.json()}" - + assert response.status_code == 201 post_id = response.json().get("id") - assert post_id is not None, "post_id가 None 입니다." + assert post_id is not None # 게시물 존재 여부 확인 time.sleep(1) # 데이터베이스 반영 시간 확보 get_post_response = client.get(f"/posts/{post_id}") - assert get_post_response.status_code == 200, f"게시물이 생성되지 않았습니다: {get_post_response.json()}" + assert get_post_response.status_code == 200 # 게시물 삭제 시도 delete_response = client.delete(f"/posts/{post_id}") - assert delete_response.status_code == 204, f"게시물 삭제 실패: {delete_response.json()}" + assert delete_response.status_code == 204 # 댓글 삭제 테스트 def test_delete_comment(): - try: - user_info = get_user_info(1) - except requests.exceptions.RequestException as e: - assert False, f"유저 정보 조회 실패: {e}" + user_info = get_user_info(1) # 게시물 생성 response = client.post("/posts/", json={ @@ -89,7 +68,7 @@ def test_delete_comment(): "content": "This post has a comment", "author_id": user_info["id"] }) - assert response.status_code == 201, f"게시물 생성 실패: {response.json()}" + assert response.status_code == 201 post_id = response.json()["id"] # 댓글 추가 @@ -97,12 +76,12 @@ def test_delete_comment(): "content": "This comment will be deleted", "author_id": user_info["id"] }) - assert comment_response.status_code == 201, f"댓글 생성 실패: {comment_response.json()}" + assert comment_response.status_code == 201 comment_id = comment_response.json()["id"] # 댓글 삭제 delete_comment_response = client.delete(f"/posts/{post_id}/comments/{comment_id}") - assert delete_comment_response.status_code == 204, f"댓글 삭제 실패: {delete_comment_response.json()}" + assert delete_comment_response.status_code == 204 # 존재하지 않는 유저로 게시물 생성 시도 테스트 def test_user_not_found(): @@ -111,5 +90,5 @@ def test_user_not_found(): "content": "This is a test post", "author_id": 9999 # 없는 유저 ID }) - assert response.status_code == 404, f"유저가 존재하지 않는 경우 게시물 생성 실패: {response.json()}" + assert response.status_code == 404 assert response.json()["detail"] == "유저 정보를 가져오는 데 실패했습니다." diff --git a/services/content-service/tests/test_content.py b/services/content-service/tests/test_content.py index 9c996b7..c4a0306 100644 --- a/services/content-service/tests/test_content.py +++ b/services/content-service/tests/test_content.py @@ -1,63 +1,68 @@ -# test_content.py from fastapi.testclient import TestClient -from app.main import app # FastAPI 앱을 임포트 +from app.main import app +import redis +import json -# TestClient 생성 client = TestClient(app) +# Redis 클라이언트 초기화 +redis_client = redis.Redis(host='redis', port=6379, db=0, decode_responses=True) + +# 테스트 전에 Redis 캐시를 비움 +def setup_function(): + redis_client.flushdb() + +# 콘텐츠 생성 테스트 def test_create_content(): - response = client.post( - "/contents/", - json={ - "title": "Test Movie", - "description": "A great movie", - "category": "Movie", - "creator": "John Doe" - } - ) + response = client.post("/contents/", json={ + "title": "Test Content", + "description": "This is a test content", + "category": "Test", + "creator": "Tester" + }) assert response.status_code == 201 - assert response.json()["title"] == "Test Movie" - - -def test_get_content(): - # 콘텐츠 생성 후 상태 확인 - client.post( - "/contents/", - json={ - "title": "Test Performance", - "description": "A live performance", - "category": "Performance", - "creator": "Jane Smith" - } - ) + assert response.json()["title"] == "Test Content" + +def test_get_contents_with_existing_id(): + # DB에 존재하는 콘텐츠를 조회하기 위해 미리 콘텐츠 생성 + response = client.post("/contents/", json={ + "title": "Existing Content", + "description": "This content already exists.", + "category": "Test", + "creator": "Tester" + }) + assert response.status_code == 201 + existing_content_id = response.json()["id"] - # 제목 필터링 테스트 - response = client.get("/contents/?title=Test") + # 존재하는 콘텐츠를 조회 + response = client.get(f"/contents/{existing_content_id}") assert response.status_code == 200 - contents = response.json() - assert len(contents) > 0 - assert any(content["title"] == "Test Performance" for content in contents) - - # 카테고리 필터링 테스트 - response = client.get("/contents/?category=Performance") + assert response.json()["id"] == existing_content_id # ID가 일치해야 함 + + # 캐시 확인 (콘텐츠 목록 조회) + response = client.get("/contents/") assert response.status_code == 200 - contents = response.json() - assert any(content["category"] == "Performance" for content in contents) + assert len(response.json()) > 0 # 콘텐츠가 하나 이상 있어야 함 + # 캐시 확인 + cached_data = redis_client.get(f"content_list:None:None") # 캐시 키 확인 + assert cached_data is not None # 캐시가 비어있지 않아야 함 +# 콘텐츠 삭제 테스트 (캐시 갱신 확인) def test_delete_content(): - # 콘텐츠 생성 - response = client.post( - "/contents/", - json={ - "title": "Test Art", - "description": "An amazing art", - "category": "Art", - "creator": "John Artist" - } - ) + # 새로운 콘텐츠 생성 + response = client.post("/contents/", json={ + "title": "Test Content", + "description": "This is a test content", + "category": "Test", + "creator": "Tester" + }) content_id = response.json()["id"] - + # 콘텐츠 삭제 response = client.delete(f"/contents/{content_id}") assert response.status_code == 204 + + # 삭제된 콘텐츠 조회 시 404 응답 확인 + get_response = client.get(f"/contents/{content_id}") + assert get_response.status_code == 404 # 삭제된 콘텐츠에 대해 404를 기대 diff --git a/services/docker-compose.yml b/services/docker-compose.yml index bd2cb37..c52a6c0 100644 --- a/services/docker-compose.yml +++ b/services/docker-compose.yml @@ -9,8 +9,9 @@ services: - "8000:8000" depends_on: - content-db + - redis # Redis 의존성 추가 env_file: - - ./content-service/.env # 환경 변수 파일 지정 + - ./content-service/.env # Content 서비스 환경 변수 파일 networks: - app-network command: uvicorn app.main:app --host 0.0.0.0 --port 8000 @@ -24,7 +25,7 @@ services: depends_on: - user-db env_file: - - ./user-service/.env # 환경 변수 파일 지정 + - ./user-service/.env # User 서비스 환경 변수 파일 networks: - app-network command: uvicorn app.main:app --host 0.0.0.0 --port 8001 @@ -33,9 +34,9 @@ services: image: postgres:13 container_name: content-db environment: - POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_USER: ${POSTGRES_USER} # 공통 환경 변수에서 사용 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_DB: ${POSTGRES_DB} # content-service 전용 환경 변수 ports: - "5433:5432" networks: @@ -47,7 +48,7 @@ services: environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${TEST_POSTGRES_DB} + POSTGRES_DB: ${TEST_POSTGRES_DB} # content-service 테스트 DB 환경 변수 ports: - "5435:5432" networks: @@ -57,9 +58,9 @@ services: image: postgres:13 container_name: user-db environment: - POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_USER: ${POSTGRES_USER} # 공통 환경 변수에서 사용 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${USER_POSTGRES_DB} + POSTGRES_DB: ${POSTGRES_DB} # user-service 전용 환경 변수 ports: - "5434:5432" networks: @@ -71,12 +72,20 @@ services: environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${USER_TEST_POSTGRES_DB} + POSTGRES_DB: ${USER_TEST_POSTGRES_DB} # user-service 테스트 DB 환경 변수 ports: - "5436:5432" networks: - app-network + redis: + image: redis:6 + container_name: redis + ports: + - "6379:6379" + networks: + - app-network + networks: app-network: driver: bridge diff --git a/services/requirements.txt b/services/requirements.txt new file mode 100644 index 0000000..953e31c --- /dev/null +++ b/services/requirements.txt @@ -0,0 +1,32 @@ +alembic==1.13.3 +annotated-types==0.7.0 +anyio==4.6.0 +async-timeout==4.0.3 +certifi==2024.8.30 +charset-normalizer==3.3.2 +click==8.1.7 +exceptiongroup==1.2.2 +fastapi==0.112.2 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.2 +idna==3.10 +iniconfig==2.0.0 +Mako==1.3.5 +MarkupSafe==2.1.5 +packaging==24.1 +pluggy==1.5.0 +psycopg2-binary==2.9.9 +pydantic==2.9.2 +pydantic_core==2.23.4 +pytest==8.3.3 +python-dotenv==1.0.1 +redis==5.1.1 +requests==2.32.3 +sniffio==1.3.1 +SQLAlchemy==2.0.32 +starlette==0.38.5 +tomli==2.0.1 +typing_extensions==4.12.2 +urllib3==2.2.3 +uvicorn==0.30.6 diff --git a/services/user-service/Dockerfile b/services/user-service/Dockerfile index 6d23e74..1e294fb 100644 --- a/services/user-service/Dockerfile +++ b/services/user-service/Dockerfile @@ -14,9 +14,12 @@ RUN chown -R appuser /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy only the necessary application code +# Copy the application code COPY app/ ./app +# Copy the tests directory for running tests +COPY tests/ ./tests + # Expose the port for user-service (8001) EXPOSE 8001 diff --git a/services/user-service/tests/conftest.py b/services/user-service/tests/conftest.py index 56fb40a..973cb82 100644 --- a/services/user-service/tests/conftest.py +++ b/services/user-service/tests/conftest.py @@ -1,5 +1,6 @@ -import sys import os +import sys -# 프로젝트의 루트 경로를 PYTHONPATH에 추가 -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) \ No newline at end of file +# 프로젝트 루트 경로 설정 +root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.append(root_path)