diff --git a/.aws/ecs-task-definition.json b/.aws/ecs-task-definition.json index 0f98200..bbe6412 100644 --- a/.aws/ecs-task-definition.json +++ b/.aws/ecs-task-definition.json @@ -20,6 +20,10 @@ "mountPoints": [], "volumesFrom": [], "secrets": [ + { + "name": "MONGODB_URL", + "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:864981757354:secret:xrpedia/credentials-UAy9x0:xrpedia-mongodb-url::" + } ], "ulimits": [], "logConfiguration": { diff --git a/.github/workflows/cd-ecs.yml b/.github/workflows/cd-ecs.yml index 6464832..79e4376 100644 --- a/.github/workflows/cd-ecs.yml +++ b/.github/workflows/cd-ecs.yml @@ -13,6 +13,7 @@ env: ECS_TASK_DEFINITION: ${{ vars.ECS_TASK_DEFINITION }} CONTAINER_NAME: ${{ vars.CONTAINER_NAME }} TEST_IMAGE_NAME: xrpedia-ai-proxy-test + TEST_MONGODB_URL: ${{ secrets.TEST_MONGODB_URL }} permissions: contents: read id-token: write @@ -49,6 +50,8 @@ jobs: id: run-test run: | docker run --rm \ + -e MONGODB_URL=${{ env.TEST_MONGODB_URL }} \ + -e AWS_REGION=${{ env.AWS_REGION }} \ ${{ env.TEST_IMAGE_NAME }} - name: Build, tag, and push image to Amazon ECR diff --git a/.github/workflows/ci-pytest.yml b/.github/workflows/ci-pytest.yml index 580243e..059e05b 100644 --- a/.github/workflows/ci-pytest.yml +++ b/.github/workflows/ci-pytest.yml @@ -6,6 +6,8 @@ on: env: TEST_IMAGE_NAME: xrpedia-ai-proxy-test + TEST_MONGODB_URL: ${{ secrets.TEST_MONGODB_URL }} + AWS_REGION: ${{ secrets.AWS_REGION }} jobs: build: @@ -22,4 +24,6 @@ jobs: id: run-test run: | docker run --rm \ + -e AWS_REGION=${{ env.AWS_REGION }} \ + -e MONGODB_URL=${{ env.TEST_MONGODB_URL }} \ ${{ env.TEST_IMAGE_NAME }} diff --git a/.gitignore b/.gitignore index c571e8b..b6eff0b 100644 --- a/.gitignore +++ b/.gitignore @@ -173,4 +173,6 @@ cython_debug/ # PyPI configuration file .pypirc -.DS_Store \ No newline at end of file +.DS_Store +.doc/ +.idea/ \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 81b7933..fc71d4c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -114,6 +114,27 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "dnspython" +version = "2.7.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, + {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=43)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=1.0.0)"] +idna = ["idna (>=3.7)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + [[package]] name = "dotenv" version = "0.9.9" @@ -409,6 +430,86 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pymongo" +version = "4.11.3" +description = "Python driver for MongoDB " +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pymongo-4.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:78f19598246dd61ba2a4fc4dddfa6a4f9af704fff7d81cb4fe0d02c7b17b1f68"}, + {file = "pymongo-4.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c9cbe81184ec81ad8c76ccedbf5b743639448008d68f51f9a3c8a9abe6d9a46"}, + {file = "pymongo-4.11.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9047ecb3bc47c43ada7d6f98baf8060c637b1e880c803a2bbd1dc63b49d2f92"}, + {file = "pymongo-4.11.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1a16ec731b42f6b2b4f1aa3a94e74ff2722aacf691922a2e8e607b7f6b8d9f1"}, + {file = "pymongo-4.11.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9120e25ac468fda3e3a1749695e0c5e52ff2294334fcc81e70ccb65c897bb58"}, + {file = "pymongo-4.11.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f618bd6ed5c3c08b350b157b1d9066d3d389785b7359d2b7b7d82ca4083595d3"}, + {file = "pymongo-4.11.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:98017f006e047f5ed6c99c2cb1cac71534f0e11862beeff4d0bc9227189bedcd"}, + {file = "pymongo-4.11.3-cp310-cp310-win32.whl", hash = "sha256:84b9300ed411fef776c60feab40f3ee03db5d0ac8921285c6e03a3e27efa2c20"}, + {file = "pymongo-4.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:07231d0bac54e32503507777719dd05ca63bc68896e64ea852edde2f1986b868"}, + {file = "pymongo-4.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31b5ad4ce148b201fa8426d0767517dc68424c3380ef4a981038d4d4350f10ee"}, + {file = "pymongo-4.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:505fb3facf54623b45c96e8e6ad6516f58bb8069f9456e1d7c0abdfdb6929c21"}, + {file = "pymongo-4.11.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f20467d695f49ce4c2d6cb87de458ebb3d098cbc951834a74f36a2e992a6bb"}, + {file = "pymongo-4.11.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65e8a397b03156880a099d55067daa1580a5333aaf4da3b0313bd7e1731e408f"}, + {file = "pymongo-4.11.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0992917ed259f5ca3506ec8009e7c82d398737a4230a607bf44d102cae31e1d6"}, + {file = "pymongo-4.11.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f2f0c3ab8284e0e2674367fa47774411212c86482bbbe78e8ae9fb223b8f6ee"}, + {file = "pymongo-4.11.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2240126683f55160f83f587d76955ad1e419a72d5c09539a509bd9d1e20bd53"}, + {file = "pymongo-4.11.3-cp311-cp311-win32.whl", hash = "sha256:be89776c5b8272437a85c904d45e0f1bbc0f21bf11688341938380843dd7fe5f"}, + {file = "pymongo-4.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:c237780760f891cae79abbfc52fda55b584492d5d9452762040aadb2c64ac691"}, + {file = "pymongo-4.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5f48b7faf4064e5f484989608a59503b11b7f134ca344635e416b1b12e7dc255"}, + {file = "pymongo-4.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:722f22bf18d208aa752591bde93e018065641711594e7a2fef0432da429264e8"}, + {file = "pymongo-4.11.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5be1b35c4897626327c4e8bae14655807c2bc710504fa790bc19a72403142264"}, + {file = "pymongo-4.11.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14f9e4d2172545798738d27bc6293b972c4f1f98cce248aa56e1e62c4c258ca7"}, + {file = "pymongo-4.11.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd3f7bafe441135f58d2b91a312714f423e15fed5afe3854880c8c61ad78d3ce"}, + {file = "pymongo-4.11.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73de1b9f416a2662ba95b4b49edc963d47b93760a7e2b561b932c8099d160151"}, + {file = "pymongo-4.11.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e24268e2d7ae96eab12161985b39e75a75185393134fc671f4bb1a16f50bf6f4"}, + {file = "pymongo-4.11.3-cp312-cp312-win32.whl", hash = "sha256:33a936d3c1828e4f52bed3dad6191a3618cc28ab056e2770390aec88d9e9f9ea"}, + {file = "pymongo-4.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:c4673d8ef0c8ef712491a750adf64f7998202a82abd72be5be749749275b3edb"}, + {file = "pymongo-4.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e53b98c9700bb69f33a322b648d028bfe223ad135fb04ec48c0226998b80d0e"}, + {file = "pymongo-4.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8464aff011208cf86eae28f4a3624ebc4a40783634e119b2b35852252b901ef3"}, + {file = "pymongo-4.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3742ffc1951bec1450a5a6a02cfd40ddd4b1c9416b36c70ae439a532e8be0e05"}, + {file = "pymongo-4.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a29294b508975a5dfd384f4b902cd121dc2b6e5d55ea2be2debffd2a63461cd9"}, + {file = "pymongo-4.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:051c741586ab6efafe72e027504ac4e5f01c88eceec579e4e1a438a369a61b0c"}, + {file = "pymongo-4.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b05e03a327cdef28ec2bb72c974d412d308f5cf867a472ef17f9ac95d18ec05"}, + {file = "pymongo-4.11.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dafeddf1db51df19effd0828ae75492b15d60c7faec388da08f1fe9593c88e7a"}, + {file = "pymongo-4.11.3-cp313-cp313-win32.whl", hash = "sha256:40c55afb34788ae6a6b8c175421fa46a37cfc45de41fe4669d762c3b1bbda48e"}, + {file = "pymongo-4.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:a5b8b7ba9614a081d1f932724b7a6a20847f6c9629420ae81ce827db3b599af2"}, + {file = "pymongo-4.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0f23f849693e829655f667ea18b87bf34e1395237eb45084f3495317d455beb2"}, + {file = "pymongo-4.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:62bcfa88deb4a6152a7c93bedd1a808497f6c2881424ca54c3c81964a51c5040"}, + {file = "pymongo-4.11.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2eaa0233858f72074bf0319f5034018092b43f19202bd7ecb822980c35bfd623"}, + {file = "pymongo-4.11.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a434e081017be360595237cd1aeac3d047dd38e8785c549be80748608c1d4ca"}, + {file = "pymongo-4.11.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e8aa65a9e4a989245198c249816d86cb240221861b748db92b8b3a5356bd6f1"}, + {file = "pymongo-4.11.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0a91004029d1fc9e66a800e6da4170afaa9b93bcf41299e4b5951b837b3467a"}, + {file = "pymongo-4.11.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b992904ac78cb712b42c4b7348974ba1739137c1692cdf8bf75c3eeb22881a4"}, + {file = "pymongo-4.11.3-cp313-cp313t-win32.whl", hash = "sha256:45e18bda802d95a2aed88e487f06becc3bd0b22286a25aeca8c46b8c64980dbb"}, + {file = "pymongo-4.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:07d40b831590bc458b624f421849c2b09ad2b9110b956f658b583fe01fe01c01"}, + {file = "pymongo-4.11.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4a1c241d8424c0e5d66a1710ff2b691f361b5fd354754a086ddea99ee19cc2d3"}, + {file = "pymongo-4.11.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b1aaccbcb4a5aaaa3acaabc59b30edd047c38c6cdfc97eb64e0611b6882a6d6"}, + {file = "pymongo-4.11.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be60f63a310d0d2824e9fb2ef0f821bb45d23e73446af6d50bddda32564f285d"}, + {file = "pymongo-4.11.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1b943d1b13f1232cb92762c82a5154f02b01234db8d632ea9525ab042bd7619"}, + {file = "pymongo-4.11.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afc7d1d2bd1997bb42fdba8a5a104198e4ff7990f096ac90353dcb87c69bb57f"}, + {file = "pymongo-4.11.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:730fe9a6c432669fa69af0905a7a4835e5a3752363b2ae3b34007919003394cd"}, + {file = "pymongo-4.11.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0633536b31980a8af7262edb03a20df88d8aa0ad803e48c49609b6408a33486d"}, + {file = "pymongo-4.11.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e88e99f33a89e8f58f7401201e79e29f98b2da21d4082ba50eeae0828bb35451"}, + {file = "pymongo-4.11.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a30f1b9bf79f53f995198ed42bc9b675fc38e6ec30d8f6f7e53094085b5eb803"}, + {file = "pymongo-4.11.3-cp39-cp39-win32.whl", hash = "sha256:e1872a33f1d4266c14fae1dc4744b955d0ef5d6fad87cc72141d04d8c97245dc"}, + {file = "pymongo-4.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:a19f186455e4b3af1e11ee877346418d18303800ecc688ef732b5725c2795f13"}, + {file = "pymongo-4.11.3.tar.gz", hash = "sha256:b6f24aec7c0cfcf0ea9f89e92b7d40ba18a1e18c134815758f111ecb0122e61c"}, +] + +[package.dependencies] +dnspython = ">=1.16.0,<3.0.0" + +[package.extras] +aws = ["pymongo-auth-aws (>=1.1.0,<2.0.0)"] +docs = ["furo (==2024.8.6)", "readthedocs-sphinx-search (>=0.3,<1.0)", "sphinx (>=5.3,<9)", "sphinx-autobuild (>=2020.9.1)", "sphinx-rtd-theme (>=2,<4)", "sphinxcontrib-shellcheck (>=1,<2)"] +encryption = ["certifi", "pymongo-auth-aws (>=1.1.0,<2.0.0)", "pymongocrypt (>=1.12.0,<2.0.0)"] +gssapi = ["pykerberos", "winkerberos (>=0.5.0)"] +ocsp = ["certifi", "cryptography (>=2.5)", "pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)"] +snappy = ["python-snappy"] +test = ["pytest (>=8.2)", "pytest-asyncio (>=0.24.0)"] +zstd = ["zstandard"] + [[package]] name = "pytest" version = "8.3.5" @@ -572,4 +673,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "09f6a01e0688827869ed10b5aaea9352c8d02c71c404ad67a24b3aad9502087b" +content-hash = "d74e83d5f195da6bfc3f4673248a32382a4db74d881df9c782e0d9f0d7481f09" diff --git a/pyproject.toml b/pyproject.toml index f90231e..a4f2b7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ boto3 = "^1.37.16" botocore = "^1.37.16" dotenv = "^0.9.9" httpx = "^0.28.1" +pymongo = "^4.11.3" [build-system] requires = ["poetry-core"] diff --git a/src/app.py b/src/app.py index db07f36..63ffeb1 100644 --- a/src/app.py +++ b/src/app.py @@ -1,5 +1,7 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from dotenv import load_dotenv from src.router import router @@ -26,4 +28,17 @@ allow_headers=["*"], ) + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "status": 400, + "message": "잘못된 요청입니다.", + "detail": "명세에 맞지 않은 요청입니다." + } + ) + + app.include_router(router) \ No newline at end of file diff --git a/src/main/ai/__init__.py b/src/main/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/ai/data/CategoryRecommendationQueue.py b/src/main/ai/data/CategoryRecommendationQueue.py new file mode 100644 index 0000000..5db9dcd --- /dev/null +++ b/src/main/ai/data/CategoryRecommendationQueue.py @@ -0,0 +1,35 @@ +import os +import json +import boto3 +from dotenv import load_dotenv + +load_dotenv() + + +class CategoryRecommendationQueue: + def __init__(self, sqs_client: boto3.client, queue_url: str): + self.sqs = sqs_client + self.queue_url = queue_url + + def send_message(self, request_id: str, title: str, user_id: str): + try: + message_body = { + 'request_type': 'category_recommendation', + 'request_id': request_id, + 'user_id': str(user_id), + 'payload': { + 'title': title + } + } + + response = self.sqs.send_message( + QueueUrl=self.queue_url, + MessageGroupId=str(user_id), + MessageDeduplicationId=str(request_id), + MessageBody=json.dumps(message_body) + ) + + return response + except Exception as e: + print(f"Error sending message to SQS: {e}") + raise \ No newline at end of file diff --git a/src/main/ai/data/CategoryRecommendationRepository.py b/src/main/ai/data/CategoryRecommendationRepository.py new file mode 100644 index 0000000..1813f95 --- /dev/null +++ b/src/main/ai/data/CategoryRecommendationRepository.py @@ -0,0 +1,56 @@ +from typing import Optional +from pymongo.collection import Collection +from pymongo import MongoClient +from bson import ObjectId +from datetime import datetime, timezone + + +class CategoryRecommendationRepository: + def __init__(self, client: MongoClient): + self.db = client.get_database() + self.collection: Collection = self.db.get_collection('category_recommendations') + + def create_recommendation_request(self, title: str, user_id: str) -> dict: + document = { + "title": title, + "user_id": user_id, + "is_completed": False, + "created_at": self.get_current_time() + } + result = self.collection.insert_one(document) + document["_id"] = result.inserted_id + return document + + def get_recommendation_by_id(self, request_id: str, user_id: str) -> Optional[dict]: + try: + object_id = ObjectId(request_id) + return self.collection.find_one({ + "_id": object_id, + "user_id": user_id + }) + except: + return None + + def update_recommendation_result(self, request_id: str, predicted_category: str) -> Optional[dict]: + try: + object_id = ObjectId(request_id) + result = self.collection.update_one( + {"_id": object_id}, + { + "$set": { + "is_completed": True, + "predicted_category": predicted_category, + "updated_at": self.get_current_time() + } + } + ) + + if result.modified_count == 0: + return None + + return self.collection.find_one({"_id": object_id}) + except: + return None + + def get_current_time(self): + return datetime.now(timezone.utc) \ No newline at end of file diff --git a/src/main/ai/data/__init__.py b/src/main/ai/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/ai/di/__init__.py b/src/main/ai/di/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/ai/di/dependencies.py b/src/main/ai/di/dependencies.py new file mode 100644 index 0000000..b6a9c67 --- /dev/null +++ b/src/main/ai/di/dependencies.py @@ -0,0 +1,33 @@ +import os +import boto3 +from dotenv import load_dotenv + +from src.main.ai.data.CategoryRecommendationRepository import CategoryRecommendationRepository +from src.main.ai.service.CategoryRecommendationService import CategoryRecommendationService +from src.main.ai.data.CategoryRecommendationQueue import CategoryRecommendationQueue +from src.main.config.mongodb import get_mongo_client + +load_dotenv() + + +def get_category_recommendation_repository(): + client = get_mongo_client() + return CategoryRecommendationRepository(client) + + +def get_category_recommendation_queue(): + if os.getenv('ENV') == 'local': + aws_profile = os.getenv('AWS_PROFILE', 'default') + session = boto3.Session(profile_name=aws_profile) + sqs_client = session.client('sqs') + else: + sqs_client = boto3.client('sqs', region_name=os.getenv('AWS_REGION')) + + queue_url = os.getenv('SQS_REQUEST_QUEUE_URL') + return CategoryRecommendationQueue(sqs_client, queue_url) + + +def get_category_recommendation_service(): + repository = get_category_recommendation_repository() + queue = get_category_recommendation_queue() + return CategoryRecommendationService(repository, queue) diff --git a/src/main/ai/models/CategoryRecommendation.py b/src/main/ai/models/CategoryRecommendation.py new file mode 100644 index 0000000..06b8c91 --- /dev/null +++ b/src/main/ai/models/CategoryRecommendation.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, Field +import uuid +from typing import Optional + + +class CategoryRecommendationRequest(BaseModel): + title: str + + +class CategoryRecommendationResponse(BaseModel): + request_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + + +class CategoryRecommendationStatusResponse(BaseModel): + request_id: str + is_completed: bool + predicted_category: Optional[str] = None + + +class CategoryRecommendationResultRequest(BaseModel): + predicted_category: str \ No newline at end of file diff --git a/src/main/ai/models/__init__.py b/src/main/ai/models/__init__.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/src/main/ai/models/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/ai/router/AIInternalAPIRouter.py b/src/main/ai/router/AIInternalAPIRouter.py new file mode 100644 index 0000000..59f7578 --- /dev/null +++ b/src/main/ai/router/AIInternalAPIRouter.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import JSONResponse + +from src.main.ai.di.dependencies import get_category_recommendation_service +from src.main.ai.models.CategoryRecommendation import CategoryRecommendationResultRequest, CategoryRecommendationStatusResponse +from src.main.ai.service.CategoryRecommendationService import CategoryRecommendationService + + +router = APIRouter( + prefix="/ai-proxy", + tags=["AI", "Internal"] +) + + +@router.post("/category-recommendation-results/{request_id}", response_model=CategoryRecommendationStatusResponse) +async def update_category_recommendation_result( + request_id: str, + request: CategoryRecommendationResultRequest, + service: CategoryRecommendationService = Depends(get_category_recommendation_service) +): + result = service.update_recommendation_result(request_id, request) + + if not result: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={ + "status": 404, + "message": "요청을 찾을 수 없습니다.", + "detail": "존재하지 않는 ID입니다." + } + ) + + return result diff --git a/src/main/ai/router/AIPublicAPIRouter.py b/src/main/ai/router/AIPublicAPIRouter.py new file mode 100644 index 0000000..7c4cdf4 --- /dev/null +++ b/src/main/ai/router/AIPublicAPIRouter.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends, HTTPException, status +import uuid + +from src.main.auth.dependencies import get_current_user +from src.main.ai.di.dependencies import get_category_recommendation_service +from src.main.ai.models.CategoryRecommendation import CategoryRecommendationRequest, CategoryRecommendationResponse, CategoryRecommendationStatusResponse +from src.main.ai.service.CategoryRecommendationService import CategoryRecommendationService + + +router = APIRouter( + prefix="/ai", + tags=["AI", "Public"] +) + + +@router.post("/category-recommendations", response_model=CategoryRecommendationResponse) +async def create_category_recommendation_request( + request: CategoryRecommendationRequest, + user_id: uuid.UUID = Depends(get_current_user), + service: CategoryRecommendationService = Depends(get_category_recommendation_service) +): + """ + 자료 제목에 따른 추천 카테고리 요청 + """ + return service.create_recommendation_request(request, user_id) + + +@router.get("/category-recommendations/{request_id}", response_model=CategoryRecommendationStatusResponse) +async def get_category_recommendation_status( + request_id: str, + user_id: uuid.UUID = Depends(get_current_user), + service: CategoryRecommendationService = Depends(get_category_recommendation_service) +): + """ + 자료 제목에 따른 추천 카테고리 조회 + """ + result = service.get_recommendation_status(request_id, user_id) + + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="요청을 찾을 수 없습니다. 존재하지 않는 ID입니다." + ) + + return result diff --git a/src/main/ai/router/__init__.py b/src/main/ai/router/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/ai/service/CategoryRecommendationService.py b/src/main/ai/service/CategoryRecommendationService.py new file mode 100644 index 0000000..9638859 --- /dev/null +++ b/src/main/ai/service/CategoryRecommendationService.py @@ -0,0 +1,64 @@ +from typing import Optional +import uuid +from bson import ObjectId + +from src.main.ai.data.CategoryRecommendationRepository import CategoryRecommendationRepository +from src.main.ai.data.CategoryRecommendationQueue import CategoryRecommendationQueue +from src.main.ai.models.CategoryRecommendation import ( + CategoryRecommendationRequest, + CategoryRecommendationResponse, + CategoryRecommendationStatusResponse, + CategoryRecommendationResultRequest +) + + +class CategoryRecommendationService: + def __init__(self, repository: CategoryRecommendationRepository, queue: CategoryRecommendationQueue): + self.repository = repository + self.queue = queue + + def create_recommendation_request(self, request: CategoryRecommendationRequest, user_id: uuid.UUID) -> CategoryRecommendationResponse: + # MongoDB에 저장 - ObjectId 자동 생성 + document = self.repository.create_recommendation_request( + title=request.title, + user_id=str(user_id) + ) + + # request_id는 MongoDB의 _id를 문자열로 변환 + request_id = str(document["_id"]) + + # 메시지 발행 + self.queue.send_message( + request_id=request_id, + title=request.title, + user_id=str(user_id) + ) + + return CategoryRecommendationResponse(request_id=request_id) + + def get_recommendation_status(self, request_id: str, user_id: uuid.UUID) -> Optional[CategoryRecommendationStatusResponse]: + result = self.repository.get_recommendation_by_id(request_id, str(user_id)) + + if not result: + return None + + return CategoryRecommendationStatusResponse( + request_id=str(result["_id"]), + is_completed=result["is_completed"], + predicted_category=result.get("predicted_category") + ) + + def update_recommendation_result(self, request_id: str, result: CategoryRecommendationResultRequest) -> Optional[CategoryRecommendationStatusResponse]: + updated = self.repository.update_recommendation_result( + request_id=request_id, + predicted_category=result.predicted_category + ) + + if not updated: + return None + + return CategoryRecommendationStatusResponse( + request_id=str(updated["_id"]), + is_completed=updated["is_completed"], + predicted_category=updated.get("predicted_category") + ) \ No newline at end of file diff --git a/src/main/ai/service/__init__.py b/src/main/ai/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/auth/__init__.py b/src/main/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/auth/dependencies.py b/src/main/auth/dependencies.py similarity index 100% rename from src/auth/dependencies.py rename to src/main/auth/dependencies.py diff --git a/src/main/config/__init__.py b/src/main/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main/config/mongodb.py b/src/main/config/mongodb.py new file mode 100644 index 0000000..0b7f00f --- /dev/null +++ b/src/main/config/mongodb.py @@ -0,0 +1,11 @@ +import os +from dotenv import load_dotenv +from pymongo import MongoClient + +load_dotenv() + +MONGODB_URL = os.getenv("MONGODB_URL") + +def get_mongo_client(): + client = MongoClient(MONGODB_URL + "?retryWrites=true") + return client diff --git a/src/router.py b/src/router.py index 9172beb..8985743 100644 --- a/src/router.py +++ b/src/router.py @@ -1,10 +1,14 @@ from fastapi import APIRouter from src.main.health.router import HealthAPIRouter +from src.main.ai.router.AIPublicAPIRouter import router as ai_public_router +from src.main.ai.router.AIInternalAPIRouter import router as ai_internal_router router = APIRouter( prefix="", ) -router.include_router(HealthAPIRouter.router) \ No newline at end of file +router.include_router(HealthAPIRouter.router) +router.include_router(ai_public_router) +router.include_router(ai_internal_router) \ No newline at end of file diff --git a/src/tests/ai/__init__.py b/src/tests/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/ai/data/__init__.py b/src/tests/ai/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/ai/data/test_category_recommendation_queue.py b/src/tests/ai/data/test_category_recommendation_queue.py new file mode 100644 index 0000000..a6dafef --- /dev/null +++ b/src/tests/ai/data/test_category_recommendation_queue.py @@ -0,0 +1,76 @@ +import pytest +import json +from unittest.mock import MagicMock, patch +from src.main.ai.data.CategoryRecommendationQueue import CategoryRecommendationQueue + + +class TestCategoryRecommendationQueue: + def setup_method(self): + # 목업 SQS 클라이언트 생성 + self.mock_sqs_client = MagicMock() + self.queue_url = "https://example.com/queue" + + # 테스트 대상 큐 생성 + self.queue = CategoryRecommendationQueue(self.mock_sqs_client, self.queue_url) + + def test_send_message_success(self): + # given + request_id = "test-request-id" + title = "테스트 제목" + user_id = "test-user-id" + + # SQS 응답 설정 + expected_response = {"MessageId": "1234567890"} + self.mock_sqs_client.send_message.return_value = expected_response + + # when + result = self.queue.send_message(request_id, title, user_id) + + # then + expected_message_body = { + 'request_type': 'category_recommendation', + 'request_id': request_id, + 'user_id': user_id, + 'payload': { + 'title': title + } + } + + self.mock_sqs_client.send_message.assert_called_once_with( + QueueUrl=self.queue_url, + MessageGroupId=user_id, + MessageDeduplicationId=request_id, + MessageBody=json.dumps(expected_message_body) + ) + assert result == expected_response + + def test_send_message_with_exception(self): + # given + request_id = "test-request-id" + title = "테스트 제목" + user_id = "test-user-id" + + # SQS 예외 발생 설정 + self.mock_sqs_client.send_message.side_effect = Exception("SQS error") + + # when/then + with pytest.raises(Exception) as e: + self.queue.send_message(request_id, title, user_id) + + assert "SQS error" in str(e.value) + + expected_message_body = { + 'request_type': 'category_recommendation', + 'request_id': request_id, + 'user_id': user_id, + 'payload': { + 'title': title + } + } + + self.mock_sqs_client.send_message.assert_called_once_with( + QueueUrl=self.queue_url, + MessageGroupId=user_id, + MessageDeduplicationId=request_id, + MessageBody=json.dumps(expected_message_body) + ) \ No newline at end of file diff --git a/src/tests/ai/data/test_category_recommendation_repository.py b/src/tests/ai/data/test_category_recommendation_repository.py new file mode 100644 index 0000000..651b0a3 --- /dev/null +++ b/src/tests/ai/data/test_category_recommendation_repository.py @@ -0,0 +1,158 @@ +import pytest +from unittest.mock import MagicMock, patch, ANY +from bson import ObjectId +from datetime import datetime, timezone +from src.main.ai.data.CategoryRecommendationRepository import CategoryRecommendationRepository + + +class TestCategoryRecommendationRepository: + def setup_method(self): + # 목업 MongoDB 클라이언트 생성 + self.mock_client = MagicMock() + self.mock_db = MagicMock() + self.mock_collection = MagicMock() + + # 클라이언트에서 DB와 컬렉션 반환하도록 설정 + self.mock_client.get_database.return_value = self.mock_db + self.mock_db.get_collection.return_value = self.mock_collection + + # 테스트 대상 리포지토리 생성 + self.repository = CategoryRecommendationRepository(self.mock_client) + + # 현재 시간을 일정하게 만들기 위한 패치 + self.time_patcher = patch.object( + self.repository, 'get_current_time', + return_value=datetime(2023, 1, 1, tzinfo=timezone.utc) + ) + self.mock_time = self.time_patcher.start() + + def teardown_method(self): + # 패치 제거 + self.time_patcher.stop() + + def test_create_recommendation_request(self): + # given + title = "테스트 제목" + user_id = "test-user-id" + mock_id = ObjectId("6123456789abcdef01234567") + + # MongoDB insert_one의 응답 설정 + self.mock_collection.insert_one.return_value = MagicMock(inserted_id=mock_id) + + # when + result = self.repository.create_recommendation_request(title, user_id) + + # then + expected_doc = { + "title": title, + "user_id": user_id, + "is_completed": False, + "created_at": datetime(2023, 1, 1, tzinfo=timezone.utc), + "_id": mock_id + } + + # ANY를 사용하여 document의 내용을 확인하지 않고 호출 자체만 확인 + self.mock_collection.insert_one.assert_called_once() + assert result == expected_doc + + def test_get_recommendation_by_id(self): + # given + request_id = "6123456789abcdef01234567" + user_id = "test-user-id" + expected_result = { + "_id": ObjectId(request_id), + "title": "테스트 제목", + "user_id": user_id, + "is_completed": False, + "created_at": datetime(2023, 1, 1, tzinfo=timezone.utc) + } + + self.mock_collection.find_one.return_value = expected_result + + # when + result = self.repository.get_recommendation_by_id(request_id, user_id) + + # then + self.mock_collection.find_one.assert_called_once_with({ + "_id": ObjectId(request_id), + "user_id": user_id + }) + assert result == expected_result + + def test_get_recommendation_by_id_invalid_id(self): + # given + request_id = "invalid-id" + user_id = "test-user-id" + + # when + result = self.repository.get_recommendation_by_id(request_id, user_id) + + # then + assert result is None + self.mock_collection.find_one.assert_not_called() + + def test_update_recommendation_result(self): + # given + request_id = "6123456789abcdef01234567" + predicted_category = "기술" + + # MongoDB 응답 설정 + update_result = MagicMock(modified_count=1) + self.mock_collection.update_one.return_value = update_result + + expected_doc = { + "_id": ObjectId(request_id), + "title": "테스트 제목", + "user_id": "test-user-id", + "is_completed": True, + "predicted_category": predicted_category, + "updated_at": datetime(2023, 1, 1, tzinfo=timezone.utc) + } + self.mock_collection.find_one.return_value = expected_doc + + # when + result = self.repository.update_recommendation_result(request_id, predicted_category) + + # then + self.mock_collection.update_one.assert_called_once_with( + {"_id": ObjectId(request_id)}, + { + "$set": { + "is_completed": True, + "predicted_category": predicted_category, + "updated_at": datetime(2023, 1, 1, tzinfo=timezone.utc) + } + } + ) + self.mock_collection.find_one.assert_called_once_with({"_id": ObjectId(request_id)}) + assert result == expected_doc + + def test_update_recommendation_result_invalid_id(self): + # given + request_id = "invalid-id" + predicted_category = "기술" + + # when + result = self.repository.update_recommendation_result(request_id, predicted_category) + + # then + assert result is None + self.mock_collection.update_one.assert_not_called() + self.mock_collection.find_one.assert_not_called() + + def test_update_recommendation_result_not_found(self): + # given + request_id = "6123456789abcdef01234567" + predicted_category = "기술" + + # MongoDB 응답 설정 - modified_count가 0이면 업데이트된 문서가 없음 + update_result = MagicMock(modified_count=0) + self.mock_collection.update_one.return_value = update_result + + # when + result = self.repository.update_recommendation_result(request_id, predicted_category) + + # then + self.mock_collection.update_one.assert_called_once() + self.mock_collection.find_one.assert_not_called() + assert result is None \ No newline at end of file diff --git a/src/tests/ai/models/__init__.py b/src/tests/ai/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/ai/models/test_category_recommendation_models.py b/src/tests/ai/models/test_category_recommendation_models.py new file mode 100644 index 0000000..43ec33e --- /dev/null +++ b/src/tests/ai/models/test_category_recommendation_models.py @@ -0,0 +1,93 @@ +import pytest +import uuid +from pydantic import ValidationError + +from src.main.ai.models.CategoryRecommendation import ( + CategoryRecommendationRequest, + CategoryRecommendationResponse, + CategoryRecommendationStatusResponse, + CategoryRecommendationResultRequest +) + + +class TestCategoryRecommendationModels: + def test_category_recommendation_request_valid(self): + # given + title = "테스트 제목" + + # when + model = CategoryRecommendationRequest(title=title) + + # then + assert model.title == title + + def test_category_recommendation_request_invalid(self): + # when/then - 제목이 없는 경우 검증 오류 + with pytest.raises(ValidationError): + CategoryRecommendationRequest() + + def test_category_recommendation_response(self): + # given + request_id = "test-request-id" + + # when + model = CategoryRecommendationResponse(request_id=request_id) + + # then + assert model.request_id == request_id + + def test_category_recommendation_response_default_id(self): + # when + model = CategoryRecommendationResponse() + + # then + assert model.request_id is not None + # UUID 형식으로 변환 가능한지 확인 + uuid.UUID(model.request_id) + + def test_category_recommendation_status_response_incomplete(self): + # given + request_id = "test-request-id" + + # when + model = CategoryRecommendationStatusResponse( + request_id=request_id, + is_completed=False + ) + + # then + assert model.request_id == request_id + assert model.is_completed is False + assert model.predicted_category is None + + def test_category_recommendation_status_response_complete(self): + # given + request_id = "test-request-id" + category = "기술" + + # when + model = CategoryRecommendationStatusResponse( + request_id=request_id, + is_completed=True, + predicted_category=category + ) + + # then + assert model.request_id == request_id + assert model.is_completed is True + assert model.predicted_category == category + + def test_category_recommendation_result_request_valid(self): + # given + category = "기술" + + # when + model = CategoryRecommendationResultRequest(predicted_category=category) + + # then + assert model.predicted_category == category + + def test_category_recommendation_result_request_invalid(self): + # when/then - 카테고리가 없는 경우 검증 오류 + with pytest.raises(ValidationError): + CategoryRecommendationResultRequest() \ No newline at end of file diff --git a/src/tests/ai/router/__init__.py b/src/tests/ai/router/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/ai/router/test_ai_internal_api_router.py b/src/tests/ai/router/test_ai_internal_api_router.py new file mode 100644 index 0000000..fd48baa --- /dev/null +++ b/src/tests/ai/router/test_ai_internal_api_router.py @@ -0,0 +1,77 @@ +import pytest +from unittest.mock import MagicMock, patch +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.main.ai.models.CategoryRecommendation import CategoryRecommendationStatusResponse +from src.main.ai.router.AIInternalAPIRouter import router as internal_router + + +# 테스트용 앱 생성 +app = FastAPI() +app.include_router(internal_router) + +@pytest.fixture +def client(): + return TestClient(app) + + +class TestAIInternalAPIRouter: + def setup_method(self): + # 테스트 공통 데이터 + self.test_request_id = "6123456789abcdef01234567" + self.test_category = "기술" + + def test_update_category_recommendation_result_success(self, client): + # given + request_data = { + "predicted_category": self.test_category + } + + # 서비스 응답 모의 설정 + with patch('src.main.ai.service.CategoryRecommendationService.CategoryRecommendationService.update_recommendation_result') as mock_service: + # 서비스 응답 설정 + mock_service.return_value = CategoryRecommendationStatusResponse( + request_id=self.test_request_id, + is_completed=True, + predicted_category=self.test_category + ) + + # when + response = client.post(f"/ai-proxy/category-recommendation-results/{self.test_request_id}", json=request_data) + + # then + assert response.status_code == 200 + assert response.json() == { + "request_id": self.test_request_id, + "is_completed": True, + "predicted_category": self.test_category + } + + # 서비스가 한 번 호출됐는지 확인 + mock_service.assert_called_once() + + def test_update_category_recommendation_result_not_found(self, client): + # given + request_data = { + "predicted_category": self.test_category + } + + # 서비스 응답 모의 설정 - 업데이트 실패 + with patch('src.main.ai.service.CategoryRecommendationService.CategoryRecommendationService.update_recommendation_result') as mock_service: + # 서비스 응답 설정 + mock_service.return_value = None + + # when + response = client.post(f"/ai-proxy/category-recommendation-results/{self.test_request_id}", json=request_data) + + # then + assert response.status_code == 404 + assert response.json() == { + "status": 404, + "message": "요청을 찾을 수 없습니다.", + "detail": "존재하지 않는 ID입니다." + } + + # 서비스가 한 번 호출됐는지 확인 + mock_service.assert_called_once() \ No newline at end of file diff --git a/src/tests/ai/router/test_ai_public_api_router.py b/src/tests/ai/router/test_ai_public_api_router.py new file mode 100644 index 0000000..cc8e247 --- /dev/null +++ b/src/tests/ai/router/test_ai_public_api_router.py @@ -0,0 +1,96 @@ +import pytest +import uuid +from unittest.mock import MagicMock, patch +from fastapi import FastAPI, Depends +from fastapi.testclient import TestClient + +from src.main.ai.models.CategoryRecommendation import ( + CategoryRecommendationRequest, + CategoryRecommendationResponse, + CategoryRecommendationStatusResponse +) +from src.main.ai.router.AIPublicAPIRouter import router as public_router +from src.main.auth.dependencies import get_current_user + + +# 테스트용 앱 생성 +app = FastAPI() +app.include_router(public_router) + +# 고정된 사용자 ID를 반환하는 의존성 함수 +async def mock_get_current_user(): + return uuid.UUID("12345678-1234-5678-1234-567812345678") + +# 실제 의존성 대신 모의 의존성 사용 +app.dependency_overrides[get_current_user] = mock_get_current_user + +# 테스트용 클라이언트 +@pytest.fixture +def client(): + return TestClient(app) + + +class TestAIPublicAPIRouter: + def setup_method(self): + # 테스트 공통 데이터 + self.test_title = "테스트 제목" + self.test_user_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + self.test_request_id = "6123456789abcdef01234567" + + def test_create_category_recommendation_request(self, client): + # given + request_data = { + "title": self.test_title + } + + # 서비스 응답 모의 설정 + with patch('src.main.ai.service.CategoryRecommendationService.CategoryRecommendationService.create_recommendation_request') as mock_service: + # 서비스 응답 설정 + mock_service.return_value = CategoryRecommendationResponse(request_id=self.test_request_id) + + # when + response = client.post("/ai/category-recommendations", json=request_data) + + # then + assert response.status_code == 200 + assert response.json() == {"request_id": self.test_request_id} + + # 서비스가 한 번 호출됐는지 확인 + mock_service.assert_called_once() + + def test_get_category_recommendation_status_exists(self, client): + # given + with patch('src.main.ai.service.CategoryRecommendationService.CategoryRecommendationService.get_recommendation_status') as mock_service: + # 서비스 응답 설정 - 완료된 추천 + mock_service.return_value = CategoryRecommendationStatusResponse( + request_id=self.test_request_id, + is_completed=True, + predicted_category="기술" + ) + + # when + response = client.get(f"/ai/category-recommendations/{self.test_request_id}") + + # then + assert response.status_code == 200 + assert response.json() == { + "request_id": self.test_request_id, + "is_completed": True, + "predicted_category": "기술" + } + + # 서비스가 한 번 호출됐는지 확인 + mock_service.assert_called_once() + + def test_get_category_recommendation_status_not_found(self, client): + # given + with patch('src.main.ai.service.CategoryRecommendationService.CategoryRecommendationService.get_recommendation_status') as mock_service: + # 서비스 응답 설정 - 요청 없음 + mock_service.return_value = None + + # when + response = client.get(f"/ai/category-recommendations/{self.test_request_id}") + + # then + assert response.status_code == 404 + assert "요청을 찾을 수 없습니다" in response.json().get("detail", "") \ No newline at end of file diff --git a/src/tests/ai/service/__init__.py b/src/tests/ai/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/ai/service/test_category_recommendation_service.py b/src/tests/ai/service/test_category_recommendation_service.py new file mode 100644 index 0000000..47c75dd --- /dev/null +++ b/src/tests/ai/service/test_category_recommendation_service.py @@ -0,0 +1,176 @@ +import pytest +import uuid +from unittest.mock import MagicMock, patch +from bson import ObjectId +from datetime import datetime, timezone + +from src.main.ai.service.CategoryRecommendationService import CategoryRecommendationService +from src.main.ai.models.CategoryRecommendation import ( + CategoryRecommendationRequest, + CategoryRecommendationResponse, + CategoryRecommendationStatusResponse, + CategoryRecommendationResultRequest +) + + +class TestCategoryRecommendationService: + def setup_method(self): + # 목업 리포지토리 및 큐 생성 + self.mock_repository = MagicMock() + self.mock_queue = MagicMock() + + # 테스트 대상 서비스 생성 + self.service = CategoryRecommendationService(self.mock_repository, self.mock_queue) + + # 테스트 공통 데이터 + self.test_title = "테스트 제목" + self.test_user_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + self.test_request_id = "6123456789abcdef01234567" + self.test_object_id = ObjectId(self.test_request_id) + + def test_create_recommendation_request(self): + # given + request = CategoryRecommendationRequest(title=self.test_title) + + # 리포지토리 응답 설정 + mongo_document = { + "_id": self.test_object_id, + "title": self.test_title, + "user_id": str(self.test_user_id), + "is_completed": False, + "created_at": datetime(2023, 1, 1, tzinfo=timezone.utc) + } + self.mock_repository.create_recommendation_request.return_value = mongo_document + + # when + result = self.service.create_recommendation_request(request, self.test_user_id) + + # then + self.mock_repository.create_recommendation_request.assert_called_once_with( + title=self.test_title, + user_id=str(self.test_user_id) + ) + + self.mock_queue.send_message.assert_called_once_with( + request_id=self.test_request_id, + title=self.test_title, + user_id=str(self.test_user_id) + ) + + assert isinstance(result, CategoryRecommendationResponse) + assert result.request_id == self.test_request_id + + def test_get_recommendation_status_exists(self): + # given + # 리포지토리 응답 설정 - 완료되지 않은 추천 + mongo_document = { + "_id": self.test_object_id, + "title": self.test_title, + "user_id": str(self.test_user_id), + "is_completed": False, + "created_at": datetime(2023, 1, 1, tzinfo=timezone.utc) + } + self.mock_repository.get_recommendation_by_id.return_value = mongo_document + + # when + result = self.service.get_recommendation_status(self.test_request_id, self.test_user_id) + + # then + self.mock_repository.get_recommendation_by_id.assert_called_once_with( + self.test_request_id, str(self.test_user_id) + ) + + assert isinstance(result, CategoryRecommendationStatusResponse) + assert result.request_id == self.test_request_id + assert result.is_completed is False + assert result.predicted_category is None + + def test_get_recommendation_status_completed(self): + # given + # 리포지토리 응답 설정 - 완료된 추천 + mongo_document = { + "_id": self.test_object_id, + "title": self.test_title, + "user_id": str(self.test_user_id), + "is_completed": True, + "predicted_category": "기술", + "created_at": datetime(2023, 1, 1, tzinfo=timezone.utc), + "updated_at": datetime(2023, 1, 2, tzinfo=timezone.utc) + } + self.mock_repository.get_recommendation_by_id.return_value = mongo_document + + # when + result = self.service.get_recommendation_status(self.test_request_id, self.test_user_id) + + # then + self.mock_repository.get_recommendation_by_id.assert_called_once_with( + self.test_request_id, str(self.test_user_id) + ) + + assert isinstance(result, CategoryRecommendationStatusResponse) + assert result.request_id == self.test_request_id + assert result.is_completed is True + assert result.predicted_category == "기술" + + def test_get_recommendation_status_not_found(self): + # given + # 리포지토리 응답 설정 - 문서 없음 + self.mock_repository.get_recommendation_by_id.return_value = None + + # when + result = self.service.get_recommendation_status(self.test_request_id, self.test_user_id) + + # then + self.mock_repository.get_recommendation_by_id.assert_called_once_with( + self.test_request_id, str(self.test_user_id) + ) + + assert result is None + + def test_update_recommendation_result_success(self): + # given + request = CategoryRecommendationResultRequest(predicted_category="기술") + + # 리포지토리 응답 설정 + updated_document = { + "_id": self.test_object_id, + "title": self.test_title, + "user_id": str(self.test_user_id), + "is_completed": True, + "predicted_category": "기술", + "created_at": datetime(2023, 1, 1, tzinfo=timezone.utc), + "updated_at": datetime(2023, 1, 2, tzinfo=timezone.utc) + } + self.mock_repository.update_recommendation_result.return_value = updated_document + + # when + result = self.service.update_recommendation_result(self.test_request_id, request) + + # then + self.mock_repository.update_recommendation_result.assert_called_once_with( + request_id=self.test_request_id, + predicted_category=request.predicted_category + ) + + assert isinstance(result, CategoryRecommendationStatusResponse) + assert result.request_id == self.test_request_id + assert result.is_completed is True + assert result.predicted_category == "기술" + + def test_update_recommendation_result_not_found(self): + # given + request = CategoryRecommendationResultRequest(predicted_category="기술") + + # 리포지토리 응답 설정 - 업데이트 실패 + self.mock_repository.update_recommendation_result.return_value = None + + # when + result = self.service.update_recommendation_result(self.test_request_id, request) + + # then + self.mock_repository.update_recommendation_result.assert_called_once_with( + request_id=self.test_request_id, + predicted_category=request.predicted_category + ) + + assert result is None \ No newline at end of file