diff --git a/README.md b/README.md index e40aebd..37700d8 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,50 @@ FastAPI와 MySQL을 사용한 카카오톡 챗봇 개발을 위한 템플릿 프 ``` ├── app/ -│ ├── scripts/ -│ │ └── core/ -│ │ └── endpoints.py +│ ├── core/ +│ │ └── __init__.py +│ ├── models/ +│ │ ├── __init__.py +│ │ ├── base.py +│ │ ├── category.py +│ │ ├── employee_category.py +│ │ ├── employeee_hire_type.py +│ │ ├── employee.py +│ │ ├── feature.py +│ │ ├── hire_type.py +│ │ ├── news.py +│ │ ├── user_category.py +│ │ └── users.py +│ ├── routers/ +│ │ ├── employee.py +│ │ ├── feature.py +│ │ ├── news.py +│ │ └── user.py │ ├── utils/ -│ │ ├── response_builder.py -│ │ └── text_utils.py -│ └── main.py +│ │ ├── __init__.py +│ │ ├── db_manager.py +│ │ ├── init_default_data.py +│ │ ├── init_elasticsearch_index.py +│ │ ├── insert_employee_data.py +│ │ ├── news_client.py +│ │ ├── news_provider_mapping.py +│ │ └── verifier.py +│ ├── dev.Dockerfile +│ ├── Dockerfile +│ ├── main.py +│ └── test.Dockerfile ├── tests/ │ └── unit/ │ ├── routers/ -│ │ └── test_kakao_router.py +│ ├── test_add_employee_data.py +│ ├── test_add_user_favorit.py +│ ├── test_employee_DB_search.py +│ ├── test_employee_recommendation.py +│ ├── test_get_categories_by_feature.py +│ ├── test_get_my_category_list.py +│ ├── test_get_news_recommendations.py +│ ├── test_news_client.py +│ │── test_user_delete_favorite.py │ └── utils/ │ ├── response_builder/ │ │ └── test_response_builder.py @@ -63,18 +96,21 @@ FastAPI와 MySQL을 사용한 카카오톡 챗봇 개발을 위한 템플릿 프 ### 설치 및 실행 1. 저장소 클론 + ```bash git clone cd kakaotalk-chatbot-template ``` 2. 환경 변수 설정 + ```bash cp .env.example .env # .env 파일을 열어 필요한 설정 수정 ``` 3. Docker 컨테이너 실행 + ```bash docker-compose up --build ``` @@ -91,11 +127,13 @@ docker-compose up --build ### 의존성 관리 새로운 패키지 추가: + ```bash poetry add ``` 개발 의존성 추가: + ```bash poetry add --group dev ``` @@ -103,6 +141,7 @@ poetry add --group dev ### 테스트 테스트 실행: + ```bash # Docker 컨테이너 내부에서 실행 docker-compose exec app poetry run pytest @@ -114,6 +153,7 @@ docker-compose exec app poetry run pytest --cov=app --cov-report=term-missing ### 코드 품질 린팅 및 포맷팅: + ```bash # Ruff를 사용한 린팅 poetry run ruff check . @@ -134,19 +174,19 @@ poetry run black . ## 환경 변수 -| 변수명 | 설명 | 기본값 | -|--------|------|---------| -| APP_NAME | 애플리케이션 이름 | kakaotalk-chatbot | -| ENVIRONMENT | 실행 환경 | development | -| DEBUG | 디버그 모드 | True | -| HOST | 서버 호스트 | 0.0.0.0 | -| PORT | 서버 포트 | 8000 | -| DB_HOST | 데이터베이스 호스트 | db | -| DB_PORT | 데이터베이스 포트 | 3306 | -| DB_NAME | 데이터베이스 이름 | chatbot | -| DB_USER | 데이터베이스 사용자 | user | -| DB_PASSWORD | 데이터베이스 비밀번호 | password | -| SECRET_KEY | 보안 키 | your-secret-key-here | +| 변수명 | 설명 | 기본값 | +| ----------- | --------------------- | -------------------- | +| APP_NAME | 애플리케이션 이름 | kakaotalk-chatbot | +| ENVIRONMENT | 실행 환경 | development | +| DEBUG | 디버그 모드 | True | +| HOST | 서버 호스트 | 0.0.0.0 | +| PORT | 서버 포트 | 8000 | +| DB_HOST | 데이터베이스 호스트 | db | +| DB_PORT | 데이터베이스 포트 | 3306 | +| DB_NAME | 데이터베이스 이름 | chatbot | +| DB_USER | 데이터베이스 사용자 | user | +| DB_PASSWORD | 데이터베이스 비밀번호 | password | +| SECRET_KEY | 보안 키 | your-secret-key-here | ## 기여하기 diff --git a/app/main.py b/app/main.py index e668873..efb3ca0 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,8 @@ - 기본 루트 엔드포인트 제공 """ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -16,11 +18,21 @@ from app.routers.feature import router as feature_router from app.routers.news import router as news_router from app.routers.user import router as user_router +from app.utils.init_elasticsearch_index import create_category_index + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # 서버 시작 시 실행할 코드 + create_category_index() + yield + # 서버 종료 시 실행할 코드 (필요 시 여기에 정리 작업 가능) app = FastAPI( title="KakaoTalk Chatbot API", description="KakaoTalk Chatbot Template API", - version="0.1.0" + version="0.1.0", + lifespan=lifespan ) # CORS 미들웨어 설정 diff --git a/app/routers/employee.py b/app/routers/employee.py index d139512..03a2c8a 100644 --- a/app/routers/employee.py +++ b/app/routers/employee.py @@ -4,7 +4,7 @@ 사용자의 구독 정보를 바탕으로 관련된 채용 공고를 필터링하여 반환합니다. """ - +from elasticsearch import Elasticsearch from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session @@ -13,7 +13,7 @@ router = APIRouter() db_dependency = Depends(db_manager.get_db) # 전역 변수로 설정 - +es = Elasticsearch("http://elasticsearch:9200") @router.get("/recommend") def get_recruit_recommendations( @@ -80,3 +80,133 @@ def get_recruit_recommendations( "results": jobs, "message": message } + +@router.get("/DB_search") +def search_employees( + user_id: str = Query(..., description="사용자 ID"), + keyword: str = Query(..., description="검색할 카테고리 키워드 (예: '정보통신', '디자인')"), + limit: int = Query(10, ge=1, le=100, description="검색 결과 최대 개수 (기본값: 10, 최대: 100)"), + db: Session = db_dependency +): + """ + 사용자가 입력한 키워드를 기반으로 가장 유사한 카테고리를 찾고, 해당 카테고리에 속한 채용 공고를 반환합니다. + + Args: + user_id (str): 검색을 수행할 사용자 ID. + keyword (str): 검색 키워드 (카테고리명). + limit (int): 반환할 채용 공고 수 (기본값: 10, 최대 100). + db (Session): 데이터베이스 세션 객체. + + Returns: + dict: 매칭된 카테고리명과 해당 카테고리에 속한 채용 공고 목록. + - matched_category (str): 검색 키워드와 가장 유사한 카테고리명. + - results (List[dict]): 채용공고 목록 (제목, 기관, 시작일, 종료일, 상세 URL 포함). + + Raises: + HTTPException 404: 사용자가 존재하지 않는 경우. + HTTPException 500: Elasticsearch 연결 실패 또는 기타 오류 발생 시. + """ + + # ✅ 1. 사용자 존재 여부 확인 + user = db.query(Users).filter(Users.user_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + try: + # ✅ 2-1. match_phrase_prefix로 후보군 검색 (자동완성 역할) + prefix_result = es.search( + index="categories", + body={ + "size": 10, + "query": { + "bool": { + "must": { + "match_phrase_prefix": { + "category_name": { + "query": keyword + } + } + }, + "filter": { + "term": { + "feature": "employee" + } + } + } + } + } + ) + prefix_hits = prefix_result.get("hits", {}).get("hits", []) + if not prefix_hits: + # 후보군 없으면 바로 기타 처리 + matched_category = "기타" + category_id = 0 + else: + candidate_names = [hit["_source"]["category_name"] for hit in prefix_hits] + + # ✅ 2-2. BM25 기반 match 쿼리로 후보군 중 가장 유사한 카테고리 검색 + bm25_result = es.search( + index="categories", + body={ + "size": 1, + "query": { + "bool": { + "must": { + "match": { + "category_name": { + "query": keyword, + "operator": "and" + } + } + }, + "filter": { + "terms": { + "category_name.keyword": candidate_names # 후보군 필터링 + } + } + } + } + } + ) + bm25_hits = bm25_result.get("hits", {}).get("hits", []) + if bm25_hits: + matched_source = bm25_hits[0]["_source"] + matched_category = matched_source["category_name"] + category_id = matched_source["category_id"] + else: + # BM25가 후보군 중 적합한 것을 못 찾으면 prefix 후보 중 1순위 반환 + matched_category = prefix_hits[0]["_source"]["category_name"] + category_id = prefix_hits[0]["_source"]["category_id"] + + except ConnectionError as e: + raise HTTPException(status_code=500, detail="Elasticsearch 연결 실패") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + # ✅ 3. 해당 카테고리에 속한 채용 공고 최신순 조회 + jobs = ( + db.query(Employee) + .join(EmployeeCategory, Employee.recruit_id == EmployeeCategory.recruit_id) + .filter(EmployeeCategory.category_id == category_id) + .order_by(Employee.start_date.desc()) + .limit(limit) + .all() + ) + + # ✅ 4. 결과를 JSON 형태로 정리 + results = [ + { + "title": job.title, + "institution": job.institution, + "start_date": job.start_date.isoformat(), # date → 문자열 (ISO 포맷) + "end_date": job.end_date.isoformat(), + "url": job.detail_url + } + for job in jobs + ] + + # ✅ 5. 최종 응답 반환 + return { + "matched_category": matched_category, + "results": results + } diff --git a/app/utils/init_default_data.py b/app/utils/init_default_data.py index cbbe546..64ff8bb 100644 --- a/app/utils/init_default_data.py +++ b/app/utils/init_default_data.py @@ -70,6 +70,9 @@ def add_default_categories(db): Category(category_name="환경·에너지·안전", feature=feature_employee), Category(category_name="농림어업", feature=feature_employee), Category(category_name="연구", feature=feature_employee), + + + Category(category_id = 0, category_name="연구", feature=feature_employee), ] for category in categories: diff --git a/app/utils/init_elasticsearch_index.py b/app/utils/init_elasticsearch_index.py new file mode 100644 index 0000000..f291537 --- /dev/null +++ b/app/utils/init_elasticsearch_index.py @@ -0,0 +1,121 @@ +""" +Elasticsearch에 카테고리 정보를 색인하는 모듈입니다. +이 모듈은 사전에 정의된 카테고리 리스트(CATEGORIES)를 기반으로 +Elasticsearch 'categories' 인덱스를 생성하고, 자동완성 기능을 지원하기 위해 +edge_ngram 분석기를 적용한 매핑을 설정합니다. +주요 기능은 인덱스 삭제 후 재생성 및 초기 데이터 삽입입니다. +""" +from elasticsearch import Elasticsearch + +es = Elasticsearch("http://elasticsearch:9200") + +CATEGORIES = [ + {"category_id": 1, "category_name": "IT/과학", "feature": "news"}, + {"category_id": 2, "category_name": "마케팅", "feature": "news"}, + {"category_id": 3, "category_name": "디자인", "feature": "news"}, + {"category_id": 4, "category_name": "경영/기획", "feature": "news"}, + {"category_id": 5, "category_name": "영업/제휴", "feature": "news"}, + {"category_id": 6, "category_name": "정치", "feature": "news"}, + {"category_id": 7, "category_name": "경제", "feature": "news"}, + {"category_id": 8, "category_name": "사회", "feature": "news"}, + {"category_id": 9, "category_name": "생활/문화", "feature": "news"}, + {"category_id": 10, "category_name": "세계", "feature": "news"}, + {"category_id": 11, "category_name": "사업관리", "feature": "employee"}, + {"category_id": 12, "category_name": "경영·회계·사무", "feature": "employee"}, + {"category_id": 13, "category_name": "금융·보험", "feature": "employee"}, + {"category_id": 14, "category_name": "교육·자연·사회과학", "feature": "employee"}, + {"category_id": 15, "category_name": "법률·경찰·소방·교도·국방", "feature": "employee"}, + {"category_id": 16, "category_name": "보건·의료", "feature": "employee"}, + {"category_id": 17, "category_name": "사회복지·종교", "feature": "employee"}, + {"category_id": 18, "category_name": "문화·예술·디자인·방송", "feature": "employee"}, + {"category_id": 19, "category_name": "운전·운송", "feature": "employee"}, + {"category_id": 20, "category_name": "영업판매", "feature": "employee"}, + {"category_id": 21, "category_name": "경비·청소", "feature": "employee"}, + {"category_id": 22, "category_name": "이용·숙박·여행·오락·스포츠", "feature": "employee"}, + {"category_id": 23, "category_name": "음식서비스", "feature": "employee"}, + {"category_id": 24, "category_name": "건설", "feature": "employee"}, + {"category_id": 25, "category_name": "기계", "feature": "employee"}, + {"category_id": 26, "category_name": "재료", "feature": "employee"}, + {"category_id": 27, "category_name": "화학", "feature": "employee"}, + {"category_id": 28, "category_name": "섬유·의복", "feature": "employee"}, + {"category_id": 29, "category_name": "전기·전자", "feature": "employee"}, + {"category_id": 30, "category_name": "정보통신", "feature": "employee"}, + {"category_id": 31, "category_name": "식품가공", "feature": "employee"}, + {"category_id": 32, "category_name": "인쇄·목재·가구·공예", "feature": "employee"}, + {"category_id": 33, "category_name": "환경·에너지·안전", "feature": "employee"}, + {"category_id": 34, "category_name": "농림어업", "feature": "employee"}, + {"category_id": 35, "category_name": "연구", "feature": "employee"}, +] + +def create_category_index(): + """ + Elasticsearch에 'categories' 인덱스를 생성하고, + 사전에 정의된 CATEGORIES 리스트의 데이터를 색인한다. + 기존에 'categories' 인덱스가 존재하면 삭제 후 새로 생성한다. + 인덱스 매핑은 다음과 같다: + - category_id: 정수형 + - category_name: 텍스트, edge_ngram 기반 자동완성(analyzer: autocomplete) 적용 + - feature: 키워드형, 카테고리 구분용 (예: 'news', 'employee') + """ + index_name = "categories" + + if es.indices.exists(index="categories"): + es.indices.delete(index="categories") + print(f"🗑️ 기존 Elasticsearch 인덱스 '{index_name}' 삭제 완료") + + index_body = { + "settings": { + "analysis": { + "tokenizer": { + "edge_ngram_tokenizer": { + "type": "edge_ngram", + "min_gram": 2, + "max_gram": 10, + "token_chars": ["letter", "digit"] + } + }, + "analyzer": { + "autocomplete": { + "type": "custom", + "tokenizer": "edge_ngram_tokenizer", + "filter": ["lowercase"] + } + } + } + }, + "mappings": { + "properties": { + "category_id": {"type": "integer"}, + "category_name": { + "type": "text", + "analyzer": "autocomplete", # 색인 시 edge_ngram 기반 분석 + "search_analyzer": "standard" # 검색 시 표준 분석기 사용 + }, + "feature": {"type": "keyword"} + } + } + } + + try: + es.indices.create(index=index_name, body=index_body) + print(f"✅ Elasticsearch 인덱스 '{index_name}' 생성 완료") + except Exception as e: + print(f"❌ 인덱스 생성 실패: {e}") + return + + success_count = 0 + for cat in CATEGORIES: + try: + es.index(index=index_name, document=cat) + success_count += 1 + except Exception as e: + print(f"⚠️ 색인 실패 (카테고리: {cat['category_name']}): {e}") + + print(f"📦 총 {success_count}개의 카테고리가 '{index_name}' 인덱스에 색인됨") + +if __name__ == "__main__": + """ + 메인 실행 시 create_category_index() 함수를 호출하여 + Elasticsearch에 'categories' 인덱스를 생성하고 초기 데이터를 색인한다. + """ + create_category_index() diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a01aa3f..9eed70d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,6 +22,7 @@ services: - ./app:/app depends_on: - db + - elasticsearch # <-- Elasticsearch 먼저 기동되도록 추가 networks: - default @@ -39,6 +40,33 @@ services: networks: - default + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0 + container_name: elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - ES_JAVA_OPTS=-Xms512m -Xmx512m # 메모리 조정 + ports: + - '9200:9200' + volumes: + - ./volumes/esdata:/usr/share/elasticsearch/data + networks: + - default + + kibana: + image: docker.elastic.co/kibana/kibana:8.12.0 + container_name: kibana + ports: + - '5601:5601' + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + networks: + - default + +volumes: + esdata: + networks: default: driver: bridge diff --git a/docker-compose.test.yml b/docker-compose.test.yml index b80837c..859e7ae 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -22,6 +22,7 @@ services: - ./app:/app depends_on: - db + - elasticsearch # <-- Elasticsearch 먼저 기동되도록 추가 networks: - default @@ -39,6 +40,32 @@ services: networks: - default + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0 + container_name: elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - ES_JAVA_OPTS=-Xms512m -Xmx512m # 메모리 조정 + ports: + - '9200:9200' + volumes: + - esdata:/usr/share/elasticsearch/data + networks: + - default + + kibana: + image: docker.elastic.co/kibana/kibana:8.12.0 + container_name: kibana + ports: + - '5601:5601' + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + networks: + - default + +volumes: + esdata: networks: default: diff --git a/docker-compose.yml b/docker-compose.yml index 508c22b..10d953f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - ./app:/app depends_on: - db + - elasticsearch # <-- Elasticsearch 먼저 기동되도록 추가 networks: - default @@ -39,6 +40,32 @@ services: networks: - default + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0 + container_name: elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - ES_JAVA_OPTS=-Xms512m -Xmx512m # 메모리 조정 + ports: + - '9200:9200' + volumes: + - esdata:/usr/share/elasticsearch/data + networks: + - default + + kibana: + image: docker.elastic.co/kibana/kibana:8.12.0 + container_name: kibana + ports: + - '5601:5601' + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + networks: + - default + +volumes: + esdata: networks: default: diff --git a/poetry.lock b/poetry.lock index 4cf1e77..3f7ef6c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -79,6 +79,18 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "certifi" +version = "2025.4.26" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, + {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, +] + [[package]] name = "click" version = "8.1.8" @@ -204,6 +216,28 @@ idna = ["idna (>=3.7)"] trio = ["trio (>=0.23)"] wmi = ["wmi (>=1.5.1)"] +[[package]] +name = "elasticsearch" +version = "7.17.12" +description = "Python client for Elasticsearch" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,<4,>=2.7" +groups = ["main"] +files = [ + {file = "elasticsearch-7.17.12-py2.py3-none-any.whl", hash = "sha256:468fd5eef703c0d9238e29bcaf3a6fe4d6b092f917959fbf41f48f8fea3df2f8"}, + {file = "elasticsearch-7.17.12.tar.gz", hash = "sha256:a1f5733ae8cf1dbf0a78593389f2503c87dd97429976099832bf0626cdfaac8b"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = ">=1.21.1,<2" + +[package.extras] +async = ["aiohttp (>=3,<4)"] +develop = ["black", "coverage", "jinja2", "mock", "pytest", "pytest-cov", "pyyaml", "requests (>=2.0.0,<3.0.0)", "sphinx (<1.7)", "sphinx-rtd-theme"] +docs = ["sphinx (<1.7)", "sphinx-rtd-theme"] +requests = ["requests (>=2.4.0,<3.0.0)"] + [[package]] name = "email-validator" version = "2.2.0" @@ -1022,6 +1056,23 @@ files = [ {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] +[[package]] +name = "urllib3" +version = "1.26.20" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["main"] +files = [ + {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"}, + {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"}, +] + +[package.extras] +brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + [[package]] name = "uvicorn" version = "0.27.1" @@ -1267,4 +1318,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "0a6195972baaad0f217546eaefa0830ba82f96eb2c10268506219f21398e2657" +content-hash = "4dbc61215b7b8c5acc80e3e40db13db4756d372597f013b41b23250b1edcc665" diff --git a/pyproject.toml b/pyproject.toml index ebdf8bd..2803639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ pytest = "^8.3.5" pytest-cov = "^6.0.0" psycopg = "^3.2.6" ruff = "^0.11.4" +elasticsearch = "^7.17.0" [tool.poetry.group.dev.dependencies] ruff = "^0.11.2" diff --git a/tests/unit/routers/test_employee_DB_search.py b/tests/unit/routers/test_employee_DB_search.py new file mode 100644 index 0000000..daaabf7 --- /dev/null +++ b/tests/unit/routers/test_employee_DB_search.py @@ -0,0 +1,309 @@ +""" +사용자 맞춤 카테고리 기반 채용 공고 검색 API 테스트 모듈. + +이 모듈은 /DB_search 엔드포인트의 기능을 테스트합니다. + +주요 테스트 항목: + - 정상적인 키워드 검색 및 채용 공고 반환 + - 존재하지 않는 사용자 처리 + - Elasticsearch에서 카테고리 미검색 시 기본 카테고리 처리 + - 해당 카테고리에 채용 공고가 없을 경우 처리 + - limit 파라미터 동작 확인 (최대 개수 제한 등) +""" + +import datetime +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.sql import text + +from app.main import app +from app.models import ( + Base, + Category, + Employee, + EmployeeCategory, + EmployeeHireType, + Feature, + HireType, + Users, +) +from app.utils.db_manager import db_manager + +# 테스트용 SQLite DB 설정 +TEST_DATABASE_URL = "sqlite:///./test.db" +engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# DB 초기화 및 테이블 생성 +@pytest.fixture(scope="function") +def setup_database(): + """ + 테스트용 데이터베이스를 초기화하고, 모든 테이블을 생성합니다. + + 이 fixture는 테스트 함수 실행 전 매번 호출되어 데이터베이스를 깨끗한 상태로 초기화합니다. + 외래 키 제약 조건을 활성화하고, 기존 테이블은 모두 삭제 후 다시 생성합니다. + + Returns: + None + """ + with engine.connect() as conn: + conn.execute(text("PRAGMA foreign_keys = ON;")) # 외래 키 활성화 + Base.metadata.drop_all(bind=conn) # 기존 테이블 삭제 + Base.metadata.create_all(bind=conn) # 테이블 생성 + +# 테스트 DB 세션 생성 및 테스트 데이터 삽입 +@pytest.fixture(scope="function") +def test_db(setup_database): + """ + 테스트용 DB 세션을 생성하고 초기 데이터를 삽입합니다. + + setup_database fixture를 사용하여 DB를 초기화한 후, 테스트에 필요한 + 사용자, 기능, 카테고리, 채용 공고 등의 기본 데이터를 삽입합니다. + + 테스트 종료 시 세션을 롤백하고 종료하며, 모든 테이블을 삭제합니다. + + Yields: + Session: 테스트용 SQLAlchemy DB 세션 + """ + db = TestingSessionLocal() + + # 기존 데이터 삭제 + db.query(Category).delete() + db.query(Feature).delete() + db.query(Users).delete() + db.commit() + + # 테스트 데이터 삽입 + user = Users(user_id="user123", user_name="홍길동") + feature = Feature(feature_type="employee") + db.add_all([user, feature]) + db.commit() + + db.refresh(user) + db.refresh(feature) + + category = Category(feature_id=1, category_name="정보통신") + db.add(category) + db.commit() + db.refresh(category) + + hire_type = HireType(hire_type_id=1, hire_type_name="정규직", hire_type_code="R1010") + db.add(hire_type) + db.commit() + db.refresh(hire_type) + + # 채용 공고 추가 + job = Employee( + recruit_id=1, + title="정보통신 개발자", + institution="TechCorp", + start_date=datetime.date(2025, 5, 1), + end_date=datetime.date(2025, 5, 31), + recrut_se="R2010", + detail_url="https://example.com/job1", + recrut_pblnt_sn=123456, + ) + db.add(job) + db.commit() + db.refresh(job) + + employee_category = EmployeeCategory( + recruit_id=1, + category_id=1, # AI 카테고리 + ) + db.add(employee_category) + db.commit() + + employee_hire_type = EmployeeHireType( + recruit_id=1, + hire_type_id=1, # 정규직 + ) + db.add(employee_hire_type) + db.commit() + + yield db # 세션 제공 + + db.commit() + db.rollback() # 테스트 종료 후 변경 사항 되돌리기 + db.close() # 세션 종료 + Base.metadata.drop_all(bind=engine) # 테스트 끝나면 DB 초기화 + +def override_get_db(): + """ + FastAPI 의존성 오버라이드용 함수. + + 테스트용 DB 세션을 생성하여 반환하며, + 사용 후 세션을 안전하게 종료합니다. + + Yields: + Session: 테스트용 SQLAlchemy DB 세션 + """ + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + +app.dependency_overrides[db_manager.get_db] = override_get_db + +@pytest.fixture(scope="function") +def client(): + """ + FastAPI 테스트 클라이언트를 생성하여 반환합니다. + + Returns: + TestClient: FastAPI 테스트 클라이언트 인스턴스 + """ + return TestClient(app) + +# 🔹 정상 검색 테스트 +@patch("app.routers.employee.es.search") # es.search 함수 패치 (실제 ES 호출 방지) +def test_search_employees_success(mock_es_search, client, test_db): + """ + Elasticsearch에서 정상적으로 카테고리 검색 결과를 받아 + 관련 채용 공고가 올바르게 반환되는지 검증하는 테스트입니다. + """ + # Elasticsearch가 "정보통신" 카테고리 검색 결과 반환 모킹 + mock_es_search.return_value = { + "hits": { + "hits": [ + { + "_source": { + "category_name": "정보통신", + "category_id": 1, + "feature": "employee" + } + } + ] + } + } + + response = client.get("/employee/DB_search", params={"user_id": "user123", "keyword": "정보통신", "limit": 5}) + assert response.status_code == 200 + + data = response.json() + assert data["matched_category"] == "정보통신" + assert isinstance(data["results"], list) + assert len(data["results"]) == 1 + assert data["results"][0]["title"] == "정보통신 개발자" + assert data["results"][0]["institution"] == "TechCorp" + +# 🔹 사용자 미존재 테스트 +def test_search_employees_user_not_found(client, setup_database): + """ + 존재하지 않는 사용자 ID로 검색 요청 시 + 404 에러 및 'User not found' 메시지가 반환되는지 확인합니다. + """ + response = client.get("/employee/DB_search", params={"user_id": "unknown", "keyword": "정보통신"}) + assert response.status_code == 404 + assert response.json() == {"detail": "User not found"} + +# 🔹 Elasticsearch에서 카테고리 미검색 시 기본값 처리 테스트 +@patch("app.routers.employee.es.search") +def test_search_employees_no_category_found(mock_es_search, client, test_db): + """ + Elasticsearch에서 키워드에 해당하는 카테고리가 검색되지 않을 때 + 기본값 '기타' 카테고리로 처리되고 빈 결과가 반환되는지 검증합니다. + """ + mock_es_search.return_value = {"hits": {"hits": []}} # 검색 결과 없음 + + response = client.get("/employee/DB_search", params={"user_id": "user123", "keyword": "없는카테고리"}) + assert response.status_code == 200 + data = response.json() + assert data["matched_category"] == "기타" + assert isinstance(data["results"], list) + assert len(data["results"]) == 0 # 기본 category_id=0 이므로 관련 공고 없음 + +# 🔹 해당 카테고리에 채용 공고가 없을 경우 +@patch("app.routers.employee.es.search") +def test_search_employees_no_jobs_in_category(mock_es_search, client, test_db): + """ + 검색된 카테고리는 존재하나 해당 카테고리에 등록된 채용 공고가 없을 때 + 빈 결과 리스트가 반환되는지 확인하는 테스트입니다. + """ + # 검색된 카테고리 ID를 존재하나, 해당 카테고리에 공고 없음 + mock_es_search.return_value = { + "hits": { + "hits": [ + { + "_source": { + "category_name": "정보통신", + "category_id": 9999, # 존재하지 않는 카테고리ID + "feature": "employee" + } + } + ] + } + } + + response = client.get("/employee/DB_search", params={"user_id": "user123", "keyword": "정보통신"}) + assert response.status_code == 200 + data = response.json() + assert data["matched_category"] == "정보통신" + assert isinstance(data["results"], list) + assert len(data["results"]) == 0 + +# 🔹 limit 파라미터 테스트 (최대 100, 기본 10 등) +@patch("app.routers.employee.es.search") +def test_search_employees_limit_param(mock_es_search, client, test_db): + """ + 검색 시 limit 파라미터의 기본값, 지정값, 최대값 초과 시 + 각각 올바르게 처리되는지 검증합니다. + - 기본 limit 사용 + - limit 지정 사용 + - 최대값(100) 초과 시 422 에러 발생 확인 + """ + mock_es_search.return_value = { + "hits": { + "hits": [ + { + "_source": { + "category_name": "정보통신", + "category_id": 1, + "feature": "employee" + } + } + ] + } + } + + # 기본 limit=10 + response = client.get("/employee/DB_search", params={"user_id": "user123", "keyword": "정보통신"}) + assert response.status_code == 200 + + # limit=1 지정 + response = client.get("/employee/DB_search", params={"user_id": "user123", "keyword": "정보통신", "limit": 1}) + assert response.status_code == 200 + data = response.json() + assert len(data["results"]) <= 1 + + # limit가 최대 100 초과 시도 (FastAPI에서 자동 검증되어 422 에러가 발생할 것) + response = client.get("/employee/DB_search", params={"user_id": "user123", "keyword": "정보통신", "limit": 101}) + assert response.status_code == 422 # 유효성 검증 실패 + + +# 🔹 Elasticsearch 연결 실패 예외 테스트 +@patch("app.routers.employee.es.search", side_effect=ConnectionError) +def test_search_employees_es_connection_error(mock_es_search, client, test_db): + """ + Elasticsearch 연결 실패 시 + 500 에러와 'Elasticsearch 연결 실패' 메시지가 반환되는지 확인합니다. + """ + response = client.get("/employee/DB_search", params={"user_id": "user123", "keyword": "정보통신"}) + assert response.status_code == 500 + assert response.json() == {"detail": "Elasticsearch 연결 실패"} + +# 🔹 Elasticsearch 기타 예외 테스트 +@patch("app.routers.employee.es.search", side_effect=Exception("ES 오류")) +def test_search_employees_es_other_error(mock_es_search, client, test_db): + """ + Elasticsearch 검색 중 알 수 없는 예외 발생 시 + 500 에러와 예외 메시지가 포함된 응답이 반환되는지 검증합니다. + """ + response = client.get("/employee/DB_search", params={"user_id": "user123", "keyword": "정보통신"}) + assert response.status_code == 500 + assert "ES 오류" in response.json()["detail"]