diff --git a/ai/src/ai_server/analyzer/__init__.py b/ai/src/ai_server/analyzer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai/src/ai_server/analyzer/resume_analyzer.py b/ai/src/ai_server/analyzer/resume_analyzer.py new file mode 100644 index 00000000..658b055b --- /dev/null +++ b/ai/src/ai_server/analyzer/resume_analyzer.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import structlog + +from ai_server.analyzer.sources.base import SourceExtractor +from ai_server.chain.document_analysis_chain import DocumentAnalyzer +from ai_server.storage.base import ObjectStorage + +log = structlog.get_logger(__name__) + + +class ResumeAnalyzeError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +@dataclass(frozen=True) +class ResumeAnalysisResult: + summary: str + tech_stack: list[str] + document_path: str + + +# 스토리지에서 가져오고 LLM을 통해 분석함 +class ResumeAnalyzer: + def __init__( + self, + *, + extractor: SourceExtractor, + chain: DocumentAnalyzer, + storage: ObjectStorage, + analyzed_key_template: str, + ) -> None: + self._extractor = extractor + self._chain = chain + self._storage = storage + self._analyzed_key_template = analyzed_key_template + + async def analyze( + self, + *, + resume_id: int, + file_path: str, + ) -> ResumeAnalysisResult: + log.info( + "resume.extract.start", + resume_id=resume_id, + file_path=file_path, + ) + source = await self._extractor.extract(file_path) + + if not source.text.strip(): + raise ResumeAnalyzeError( + code="EMPTY_PDF_TEXT", + message="추출된 텍스트가 비어있음", + retriable=False, + ) + + log.info( + "resume.llm.start", + resume_id=resume_id, + text_chars=len(source.text), + source_type=source.source_type, + ) + analysis = await self._chain.analyze( + text=source.text, + source_type=source.source_type, + ) + + out_key = self._analyzed_key_template.format(resume_id=resume_id) + await self._storage.put_text(out_key, analysis.markdown) + log.info( + "resume.markdown.saved", + resume_id=resume_id, + key=out_key, + md_chars=len(analysis.markdown), + ) + + return ResumeAnalysisResult( + summary=analysis.summary, + tech_stack=list(analysis.tech_stack), + document_path=out_key, + ) diff --git a/ai/src/ai_server/analyzer/sources/__init__.py b/ai/src/ai_server/analyzer/sources/__init__.py new file mode 100644 index 00000000..d8929a8d --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/__init__.py @@ -0,0 +1,13 @@ +from ai_server.analyzer.sources.base import ( + ExtractedSource, + SourceExtractor, + SourceType, +) +from ai_server.analyzer.sources.pdf import PdfSourceExtractor + +__all__ = [ + "ExtractedSource", + "SourceExtractor", + "SourceType", + "PdfSourceExtractor", +] diff --git a/ai/src/ai_server/analyzer/sources/base.py b/ai/src/ai_server/analyzer/sources/base.py new file mode 100644 index 00000000..b8a4e0cb --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/base.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Literal + +from pydantic import BaseModel, Field + +SourceType = Literal["PDF", "REPOSITORY", "WEB"] + + +# 모든 Source Extractor가 공통으로 반환하는 결과 모델 +class ExtractedSource(BaseModel): + text: str + source_type: SourceType + metadata: dict[str, Any] = Field(default_factory=dict) + + +# 단일 입력 형태에 대한 Adapter +class SourceExtractor(ABC): + + @abstractmethod + async def extract(self, locator: str) -> ExtractedSource: ... diff --git a/ai/src/ai_server/analyzer/sources/pdf.py b/ai/src/ai_server/analyzer/sources/pdf.py new file mode 100644 index 00000000..8dcf38b3 --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/pdf.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import asyncio +import io + +from pypdf import PdfReader + +from ai_server.analyzer.sources.base import ExtractedSource, SourceExtractor +from ai_server.storage.base import ObjectStorage + + +def _extract_pdf_text(data: bytes) -> str: + reader = PdfReader(io.BytesIO(data)) + parts: list[str] = [] + for page in reader.pages: + parts.append(page.extract_text() or "") + return "\n\n".join(p for p in parts if p).strip() + + +# PDF 를 읽어 페이지 텍스트를 이어붙인다. +class PdfSourceExtractor(SourceExtractor): + + def __init__(self, storage: ObjectStorage) -> None: + self._storage = storage + + async def extract(self, locator: str) -> ExtractedSource: + data = await self._storage.get_bytes(locator) + text = await asyncio.to_thread(_extract_pdf_text, data) + return ExtractedSource( + text=text, + source_type="PDF", + metadata={"locator": locator, "bytes": len(data)}, + ) diff --git a/ai/src/ai_server/chain/__init__.py b/ai/src/ai_server/chain/__init__.py new file mode 100644 index 00000000..d0f5e24a --- /dev/null +++ b/ai/src/ai_server/chain/__init__.py @@ -0,0 +1,13 @@ +from ai_server.chain.document_analysis_chain import ( + DocumentAnalysisResult, + DocumentAnalyzer, + LlmDocumentAnalyzer, + build_document_analysis_chain, +) + +__all__ = [ + "DocumentAnalysisResult", + "DocumentAnalyzer", + "LlmDocumentAnalyzer", + "build_document_analysis_chain", +] diff --git a/ai/src/ai_server/chain/document_analysis_chain.py b/ai/src/ai_server/chain/document_analysis_chain.py new file mode 100644 index 00000000..b212e9c3 --- /dev/null +++ b/ai/src/ai_server/chain/document_analysis_chain.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Protocol + +from langchain_core.output_parsers import PydanticOutputParser +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import Runnable +from pydantic import BaseModel, Field + +from ai_server.analyzer.sources.base import SourceType +from ai_server.chain.prompts.document_analysis import HUMAN_PROMPT, SYSTEM_PROMPT +from ai_server.config.settings import Settings + + +class DocumentAnalysisResult(BaseModel): + summary: str = Field(..., description="2~4 sentence Korean summary") + tech_stack: list[str] = Field(default_factory=list) + markdown: str = Field(..., description="Interviewer-facing markdown") + + +class DocumentAnalyzer(Protocol): + async def analyze( + self, *, text: str, source_type: SourceType + ) -> DocumentAnalysisResult: ... + + +# 랭체인 파이프라인 호출을 감싼다 +class LlmDocumentAnalyzer: + + def __init__(self, chain: Runnable) -> None: + self._chain = chain + + async def analyze( + self, *, text: str, source_type: SourceType + ) -> DocumentAnalysisResult: + result = await self._chain.ainvoke({"text": text, "source_type": source_type}) + if not isinstance(result, DocumentAnalysisResult): + raise TypeError( + f"chain returned {type(result).__name__}, expected DocumentAnalysisResult" + ) + return result + + +# 프롬프트 -> LLM -> 파서 하나로 묶어서 처리함 +def build_document_analysis_chain(settings: Settings) -> Runnable: + from langchain_openai import ChatOpenAI + + parser = PydanticOutputParser(pydantic_object=DocumentAnalysisResult) + prompt = ChatPromptTemplate.from_messages( + [ + ("system", SYSTEM_PROMPT), + ("human", HUMAN_PROMPT), + ] + ).partial(format_instructions=parser.get_format_instructions()) + + llm = ChatOpenAI( + model=settings.llm_pro_model, + temperature=settings.llm_pro_temperature, + api_key=settings.llm_api_key or None, + base_url=settings.llm_base_url, + ) + return prompt | llm | parser diff --git a/ai/src/ai_server/chain/prompts/__init__.py b/ai/src/ai_server/chain/prompts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai/src/ai_server/chain/prompts/document_analysis.py b/ai/src/ai_server/chain/prompts/document_analysis.py new file mode 100644 index 00000000..6cc9784c --- /dev/null +++ b/ai/src/ai_server/chain/prompts/document_analysis.py @@ -0,0 +1,23 @@ +# 문서 분석 프롬프트 템플릿 +# source_type 으로 PDF, 웹, 리포지토리 구분함 +SYSTEM_PROMPT = ( + "당신은 IT 직군 채용을 진행하는 시니어 면접관입니다. " + "지원자 자료(이력서·레포 README·기술 블로그 등)를 분석하여 면접 준비에 사용할 " + "구조화된 결과를 산출하세요.\n" + "- 사실에 근거해서 작성하고, 자료에 없는 내용을 추측해 채우지 마세요.\n" + "- 한국어로 작성하되 기술 용어는 영문 원어를 그대로 둡니다.\n" + "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." +) + +HUMAN_PROMPT = ( + "다음은 지원자 자료입니다 (출처 유형: {source_type}).\n" + "---\n" + "{text}\n" + "---\n\n" + "요구 사항:\n" + "1. summary: 2~4문장 한국어 요약.\n" + "2. tech_stack: 자료에 명시된 핵심 기술 5~15개. 영문 표기.\n" + "3. markdown: 면접관이 훑어볼 한국어 마크다운. 섹션 구조는 " + "'## 개요', '## 주요 경험', '## 기술', '## 추가 메모' 사용.\n\n" + "{format_instructions}" +) diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 0a8530af..9b719897 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -1,3 +1,5 @@ +from typing import Literal + from pydantic_settings import BaseSettings, SettingsConfigDict @@ -14,9 +16,9 @@ class Settings(BaseSettings): debug: bool = False # RabbitMQ - rabbitmq_url: str = "amqp://stackup:stackup@localhost:5672/" + rabbitmq_url: str = "amqp://stackup:stackup@localhost:38050/" - # AI consumer + # AI consumer (큐 이름, prefetch, 콜백 라우팅 등) ai_queue_resume: str = "ai.analyze.resume" ai_queue_prefetch: int = 10 ai_callback_exchange: str = "stackup.ai-to-core" @@ -24,15 +26,22 @@ class Settings(BaseSettings): ai_publisher_name: str = "ai-server" ai_idempotency_lru_size: int = 1024 - # S3 / MinIO - s3_endpoint_url: str = "http://localhost:9000" + storage_backend: Literal["local", "s3"] = "s3" + storage_local_root: str = "./var/storage" + + s3_endpoint_url: str = "http://localhost:38060" s3_access_key: str = "" s3_secret_key: str = "" s3_bucket_name: str = "stackup" + s3_region: str = "us-east-1" + + # 일단 충대 API 키 사용 + llm_api_key: str = "" + llm_base_url: str = "https://factchat-cloud.mindlogic.ai/v1/gateway" + llm_pro_model: str = "gemini-3.1-pro-preview" + llm_pro_temperature: float = 0.2 - # LLM API Keys - openai_api_key: str = "" - google_api_key: str = "" + analyzed_resume_md_key_template: str = "analyzed/resume/{resume_id}/summary.md" def get_settings() -> Settings: diff --git a/ai/src/ai_server/messaging/consumers/resume_consumer.py b/ai/src/ai_server/messaging/consumers/resume_consumer.py index 6142359e..c9ae62ff 100644 --- a/ai/src/ai_server/messaging/consumers/resume_consumer.py +++ b/ai/src/ai_server/messaging/consumers/resume_consumer.py @@ -3,6 +3,7 @@ import structlog from aio_pika.abc import AbstractIncomingMessage +from ai_server.analyzer.resume_analyzer import ResumeAnalyzeError, ResumeAnalyzer from ai_server.messaging.idempotency import LruIdempotencyStore from ai_server.messaging.publisher import CallbackPublisher from ai_server.model.envelope import Envelope @@ -14,29 +15,16 @@ log = structlog.get_logger(__name__) -def build_echo_callback( - envelope: Envelope[ResumeAnalyzeRequest], -) -> AnalysisCallbackPayload: - rid = envelope.payload.resume_id - return AnalysisCallbackPayload( - target_type="RESUME", - target_id=rid, - status="ANALYZED", - summary="[ECHO] not yet analyzed", - tech_stack=[], - document_s3_key=f"echo/resume/{rid}/echo.md", - embedding_chunk_count=0, - ) - - class ResumeConsumer: def __init__( self, *, + analyzer: ResumeAnalyzer, publisher: CallbackPublisher, idempotency: LruIdempotencyStore, callback_routing_key: str, ) -> None: + self._analyzer = analyzer self._publisher = publisher self._idempotency = idempotency self._callback_routing_key = callback_routing_key @@ -63,24 +51,79 @@ async def handle(self, message: AbstractIncomingMessage) -> None: ) return + req = envelope.payload log.info( - "resume.echo.start", + "resume.analyze.start", message_id=envelope.message_id, - resume_id=envelope.payload.resume_id, + resume_id=req.resume_id, trace_id=envelope.trace_id, ) - callback = build_echo_callback(envelope) + + payload = await self._run_and_build_payload(req, envelope.trace_id) + await self._publisher.publish( routing_key=self._callback_routing_key, message_type="callback.analysis", - payload=callback, + payload=payload, trace_id=envelope.trace_id, correlation_id=envelope.message_id, context=envelope.context, ) log.info( - "resume.echo.done", + "resume.analyze.done", message_id=envelope.message_id, - resume_id=envelope.payload.resume_id, + resume_id=req.resume_id, + status=payload.status, trace_id=envelope.trace_id, ) + + async def _run_and_build_payload( + self, + req: ResumeAnalyzeRequest, + trace_id: str, + ) -> AnalysisCallbackPayload: + try: + result = await self._analyzer.analyze( + resume_id=req.resume_id, + file_path=req.file_path, + ) + except ResumeAnalyzeError as err: + log.warning( + "resume.analyze.domain_failed", + resume_id=req.resume_id, + code=err.code, + retriable=err.retriable, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="RESUME", + target_id=req.resume_id, + status="FAILED", + error_code=err.code, + error_message=err.message, + retriable=err.retriable, + ) + except Exception as exc: + log.exception( + "resume.analyze.unexpected_failed", + resume_id=req.resume_id, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="RESUME", + target_id=req.resume_id, + status="FAILED", + error_code="UNEXPECTED", + error_message=str(exc), + retriable=True, + ) + + return AnalysisCallbackPayload( + target_type="RESUME", + target_id=req.resume_id, + status="ANALYZED", + summary=result.summary, + tech_stack=result.tech_stack, + document_path=result.document_path, + embedding_chunk_count=0, + ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index fdded71b..92ac3738 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -3,17 +3,24 @@ import structlog from aio_pika.abc import AbstractRobustQueue +from ai_server.analyzer.resume_analyzer import ResumeAnalyzer +from ai_server.analyzer.sources.pdf import PdfSourceExtractor +from ai_server.chain.document_analysis_chain import ( + LlmDocumentAnalyzer, + build_document_analysis_chain, +) from ai_server.config.settings import Settings from ai_server.messaging.connection import RabbitConnection from ai_server.messaging.consumers.resume_consumer import ResumeConsumer from ai_server.messaging.idempotency import LruIdempotencyStore from ai_server.messaging.publisher import CallbackPublisher +from ai_server.storage.factory import build_storage log = structlog.get_logger(__name__) +# FastAPI 서버가 켜져있는 동안 메세지를 보관함 class MessagingRuntime: - """Holds long-lived messaging components for FastAPI lifespan.""" def __init__(self, settings: Settings) -> None: self._settings = settings @@ -29,7 +36,18 @@ def __init__(self, settings: Settings) -> None: self._idempotency = LruIdempotencyStore( max_size=settings.ai_idempotency_lru_size, ) + + storage = build_storage(settings) + chain = build_document_analysis_chain(settings) + analyzer = ResumeAnalyzer( + extractor=PdfSourceExtractor(storage=storage), + chain=LlmDocumentAnalyzer(chain), + storage=storage, + analyzed_key_template=settings.analyzed_resume_md_key_template, + ) + self._resume_consumer = ResumeConsumer( + analyzer=analyzer, publisher=self._publisher, idempotency=self._idempotency, callback_routing_key=settings.ai_callback_routing_analysis, diff --git a/ai/src/ai_server/model/messages/analyze.py b/ai/src/ai_server/model/messages/analyze.py index a9a5f473..6970a0b1 100644 --- a/ai/src/ai_server/model/messages/analyze.py +++ b/ai/src/ai_server/model/messages/analyze.py @@ -12,7 +12,7 @@ class ResumeAnalyzeRequest(BaseModel): model_config = camel_config() resume_id: int - s3_key: str + file_path: str class AnalysisCallbackPayload(BaseModel): @@ -23,7 +23,7 @@ class AnalysisCallbackPayload(BaseModel): status: AnalysisStatus summary: str | None = None tech_stack: list[str] = [] - document_s3_key: str | None = None + document_path: str | None = None embedding_chunk_count: int = 0 error_code: str | None = None error_message: str | None = None diff --git a/ai/src/ai_server/storage/__init__.py b/ai/src/ai_server/storage/__init__.py new file mode 100644 index 00000000..d4b17699 --- /dev/null +++ b/ai/src/ai_server/storage/__init__.py @@ -0,0 +1,12 @@ +from ai_server.storage.base import ObjectStorage, StorageKeyError +from ai_server.storage.factory import build_storage +from ai_server.storage.local_fs import LocalFilesystemStorage +from ai_server.storage.s3 import S3Storage + +__all__ = [ + "ObjectStorage", + "StorageKeyError", + "LocalFilesystemStorage", + "S3Storage", + "build_storage", +] diff --git a/ai/src/ai_server/storage/base.py b/ai/src/ai_server/storage/base.py new file mode 100644 index 00000000..b9b95744 --- /dev/null +++ b/ai/src/ai_server/storage/base.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +# 스토리지 키 전용 에러 +class StorageKeyError(ValueError): + pass + + +class ObjectStorage(ABC): + + @abstractmethod + async def get_bytes(self, key: str) -> bytes: ... + + @abstractmethod + async def put_bytes( + self, + key: str, + data: bytes, + *, + content_type: str | None = None, + ) -> None: ... + + @abstractmethod + async def put_text( + self, + key: str, + text: str, + *, + content_type: str = "text/markdown; charset=utf-8", + ) -> None: ... + + @abstractmethod + async def exists(self, key: str) -> bool: ... diff --git a/ai/src/ai_server/storage/factory.py b/ai/src/ai_server/storage/factory.py new file mode 100644 index 00000000..7587d6f2 --- /dev/null +++ b/ai/src/ai_server/storage/factory.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from ai_server.config.settings import Settings +from ai_server.storage.base import ObjectStorage +from ai_server.storage.local_fs import LocalFilesystemStorage +from ai_server.storage.s3 import S3Storage + + +# 설정에 명시된 스토리지를 생성함 +def build_storage(settings: Settings) -> ObjectStorage: + backend = settings.storage_backend + if backend == "local": + return LocalFilesystemStorage(root_dir=settings.storage_local_root) + if backend == "s3": + return S3Storage( + bucket=settings.s3_bucket_name, + endpoint_url=settings.s3_endpoint_url or None, + access_key=settings.s3_access_key or None, + secret_key=settings.s3_secret_key or None, + region=settings.s3_region, + ) + raise ValueError(f"Unsupported STORAGE_BACKEND={backend!r}") diff --git a/ai/src/ai_server/storage/local_fs.py b/ai/src/ai_server/storage/local_fs.py new file mode 100644 index 00000000..2d2fc8b1 --- /dev/null +++ b/ai/src/ai_server/storage/local_fs.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import aiofiles +import aiofiles.os + +from ai_server.storage.base import ObjectStorage, StorageKeyError + + +# local 일 때 사용되는 스토리지 시스템 +class LocalFilesystemStorage(ObjectStorage): + + def __init__(self, root_dir: str) -> None: + self._root = Path(root_dir).resolve() + + @property + def root(self) -> Path: + return self._root + + def _resolve(self, key: str) -> Path: + if not key or key.startswith("/") or "\x00" in key: + raise StorageKeyError(f"invalid storage key: {key!r}") + candidate = (self._root / key).resolve() + root_str = str(self._root) + cand_str = str(candidate) + if cand_str != root_str and not cand_str.startswith(root_str + os.sep): + raise StorageKeyError(f"key escapes storage root: {key!r}") + return candidate + + async def get_bytes(self, key: str) -> bytes: + path = self._resolve(key) + async with aiofiles.open(path, "rb") as f: + return await f.read() + + async def put_bytes( + self, + key: str, + data: bytes, + *, + content_type: str | None = None, + ) -> None: + path = self._resolve(key) + await aiofiles.os.makedirs(path.parent, exist_ok=True) + async with aiofiles.open(path, "wb") as f: + await f.write(data) + + async def put_text( + self, + key: str, + text: str, + *, + content_type: str = "text/markdown; charset=utf-8", + ) -> None: + await self.put_bytes(key, text.encode("utf-8"), content_type=content_type) + + async def exists(self, key: str) -> bool: + try: + path = self._resolve(key) + except StorageKeyError: + return False + return await aiofiles.os.path.exists(path) diff --git a/ai/src/ai_server/storage/s3.py b/ai/src/ai_server/storage/s3.py new file mode 100644 index 00000000..f1235a80 --- /dev/null +++ b/ai/src/ai_server/storage/s3.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import boto3 +from botocore.client import BaseClient +from botocore.exceptions import ClientError + +from ai_server.storage.base import ObjectStorage, StorageKeyError + + +# S3 호환 스토리지 (MinIO 포함) +class S3Storage(ObjectStorage): + + def __init__( + self, + *, + bucket: str, + endpoint_url: str | None = None, + access_key: str | None = None, + secret_key: str | None = None, + region: str = "us-east-1", + client: BaseClient | None = None, + ) -> None: + if not bucket: + raise ValueError("bucket name required") + self._bucket = bucket + if client is not None: + self._client = client + else: + self._client = boto3.client( + "s3", + endpoint_url=endpoint_url or None, + aws_access_key_id=access_key or None, + aws_secret_access_key=secret_key or None, + region_name=region, + ) + + @property + def bucket(self) -> str: + return self._bucket + + @property + def client(self) -> BaseClient: + return self._client + + @staticmethod + def _validate_key(key: str) -> None: + if not key or key.startswith("/") or "\x00" in key: + raise StorageKeyError(f"invalid storage key: {key!r}") + + async def get_bytes(self, key: str) -> bytes: + self._validate_key(key) + resp = await asyncio.to_thread( + self._client.get_object, Bucket=self._bucket, Key=key + ) + body = resp["Body"] + return await asyncio.to_thread(body.read) + + async def put_bytes( + self, + key: str, + data: bytes, + *, + content_type: str | None = None, + ) -> None: + self._validate_key(key) + kwargs: dict[str, Any] = { + "Bucket": self._bucket, + "Key": key, + "Body": data, + } + if content_type: + kwargs["ContentType"] = content_type + await asyncio.to_thread(self._client.put_object, **kwargs) + + async def put_text( + self, + key: str, + text: str, + *, + content_type: str = "text/markdown; charset=utf-8", + ) -> None: + await self.put_bytes(key, text.encode("utf-8"), content_type=content_type) + + async def exists(self, key: str) -> bool: + try: + self._validate_key(key) + except StorageKeyError: + return False + try: + await asyncio.to_thread( + self._client.head_object, Bucket=self._bucket, Key=key + ) + return True + except ClientError as exc: + code = exc.response.get("Error", {}).get("Code", "") + if code in ("404", "NoSuchKey", "NotFound"): + return False + raise diff --git a/ai/tests/test_document_chain.py b/ai/tests/test_document_chain.py new file mode 100644 index 00000000..32317641 --- /dev/null +++ b/ai/tests/test_document_chain.py @@ -0,0 +1,35 @@ +import pytest +from langchain_core.runnables import RunnableLambda + +from ai_server.chain.document_analysis_chain import ( + DocumentAnalysisResult, + LlmDocumentAnalyzer, +) + + +@pytest.mark.asyncio +async def test_llm_document_analyzer_invokes_chain_with_inputs() -> None: + fake_result = DocumentAnalysisResult( + summary="요약", tech_stack=["Python"], markdown="## 개요\n..." + ) + captured: dict = {} + + async def fake_invoke(inputs: dict) -> DocumentAnalysisResult: + captured.update(inputs) + return fake_result + + analyzer = LlmDocumentAnalyzer(RunnableLambda(fake_invoke)) + out = await analyzer.analyze(text="raw text", source_type="PDF") + + assert captured == {"text": "raw text", "source_type": "PDF"} + assert out is fake_result + + +@pytest.mark.asyncio +async def test_llm_document_analyzer_rejects_wrong_type() -> None: + async def fake_invoke(_: dict) -> dict: + return {"summary": "not a model"} + + analyzer = LlmDocumentAnalyzer(RunnableLambda(fake_invoke)) + with pytest.raises(TypeError): + await analyzer.analyze(text="x", source_type="PDF") diff --git a/ai/tests/test_messages_analyze.py b/ai/tests/test_messages_analyze.py index 49a6d3ab..a699dd2d 100644 --- a/ai/tests/test_messages_analyze.py +++ b/ai/tests/test_messages_analyze.py @@ -9,10 +9,10 @@ def test_resume_request_parses() -> None: req = ResumeAnalyzeRequest.model_validate( - {"resumeId": 42, "s3Key": "resumes/raw/123/abc.pdf"} + {"resumeId": 42, "filePath": "resumes/raw/123/abc.pdf"} ) assert req.resume_id == 42 - assert req.s3_key == "resumes/raw/123/abc.pdf" + assert req.file_path == "resumes/raw/123/abc.pdf" def test_resume_request_requires_fields() -> None: @@ -20,25 +20,47 @@ def test_resume_request_requires_fields() -> None: ResumeAnalyzeRequest.model_validate({"resumeId": 42}) +def test_resume_request_serializes_camel_case() -> None: + req = ResumeAnalyzeRequest(resume_id=7, file_path="r/7.pdf") + dumped = req.model_dump(by_alias=True) + assert dumped == {"resumeId": 7, "filePath": "r/7.pdf"} + + def test_callback_success_serializes_camel_case() -> None: cb = AnalysisCallbackPayload( target_type="RESUME", target_id=42, status="ANALYZED", - summary="[ECHO] not yet analyzed", - tech_stack=[], - document_s3_key="echo/resume/42/echo.md", + summary="요약", + tech_stack=["Python", "FastAPI"], + document_path="analyzed/resume/42/summary.md", embedding_chunk_count=0, ) dumped = cb.model_dump(by_alias=True) assert dumped["targetType"] == "RESUME" assert dumped["targetId"] == 42 assert dumped["status"] == "ANALYZED" - assert dumped["techStack"] == [] - assert dumped["documentS3Key"] == "echo/resume/42/echo.md" + assert dumped["techStack"] == ["Python", "FastAPI"] + assert dumped["documentPath"] == "analyzed/resume/42/summary.md" assert dumped["embeddingChunkCount"] == 0 +def test_callback_failure_carries_error_fields() -> None: + cb = AnalysisCallbackPayload( + target_type="RESUME", + target_id=42, + status="FAILED", + error_code="EMPTY_PDF_TEXT", + error_message="no text", + retriable=False, + ) + dumped = cb.model_dump(by_alias=True) + assert dumped["status"] == "FAILED" + assert dumped["errorCode"] == "EMPTY_PDF_TEXT" + assert dumped["errorMessage"] == "no text" + assert dumped["retriable"] is False + + def test_callback_target_type_must_be_known() -> None: with pytest.raises(ValidationError): AnalysisCallbackPayload( diff --git a/ai/tests/test_pdf_extractor.py b/ai/tests/test_pdf_extractor.py new file mode 100644 index 00000000..1d4bb988 --- /dev/null +++ b/ai/tests/test_pdf_extractor.py @@ -0,0 +1,66 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ai_server.analyzer.sources.pdf import PdfSourceExtractor + + +def _fake_pdf_reader(page_texts: list[str]) -> MagicMock: + reader = MagicMock() + pages = [] + for t in page_texts: + page = MagicMock() + page.extract_text.return_value = t + pages.append(page) + reader.pages = pages + return reader + + +@pytest.mark.asyncio +async def test_extract_reads_from_storage_and_joins_pages() -> None: + storage = AsyncMock() + storage.get_bytes.return_value = b"PDF-BYTES" + + fake_reader = _fake_pdf_reader(["page one", "page two"]) + with patch( + "ai_server.analyzer.sources.pdf.PdfReader", return_value=fake_reader + ) as reader_cls: + extractor = PdfSourceExtractor(storage=storage) + result = await extractor.extract("resumes/raw/1/x.pdf") + + storage.get_bytes.assert_awaited_once_with("resumes/raw/1/x.pdf") + reader_cls.assert_called_once() + assert "page one" in result.text + assert "page two" in result.text + assert result.source_type == "PDF" + assert result.metadata["bytes"] == len(b"PDF-BYTES") + assert result.metadata["locator"] == "resumes/raw/1/x.pdf" + + +@pytest.mark.asyncio +async def test_extract_skips_none_page_text() -> None: + storage = AsyncMock() + storage.get_bytes.return_value = b"PDF-BYTES" + + fake_reader = _fake_pdf_reader(["", "real content"]) + fake_reader.pages[0].extract_text.return_value = None # pypdf may return None + + with patch("ai_server.analyzer.sources.pdf.PdfReader", return_value=fake_reader): + extractor = PdfSourceExtractor(storage=storage) + result = await extractor.extract("a.pdf") + + assert result.text == "real content" + + +@pytest.mark.asyncio +async def test_extract_returns_empty_on_empty_pdf() -> None: + storage = AsyncMock() + storage.get_bytes.return_value = b"PDF-BYTES" + + fake_reader = _fake_pdf_reader([]) + with patch("ai_server.analyzer.sources.pdf.PdfReader", return_value=fake_reader): + extractor = PdfSourceExtractor(storage=storage) + result = await extractor.extract("empty.pdf") + + assert result.text == "" + assert result.source_type == "PDF" diff --git a/ai/tests/test_resume_analyzer.py b/ai/tests/test_resume_analyzer.py new file mode 100644 index 00000000..b17c05bd --- /dev/null +++ b/ai/tests/test_resume_analyzer.py @@ -0,0 +1,65 @@ +from unittest.mock import AsyncMock + +import pytest + +from ai_server.analyzer.resume_analyzer import ( + ResumeAnalyzeError, + ResumeAnalyzer, +) +from ai_server.analyzer.sources.base import ExtractedSource +from ai_server.chain.document_analysis_chain import DocumentAnalysisResult + + +def _make_analyzer( + *, + extracted: ExtractedSource, + analysis: DocumentAnalysisResult | None = None, +) -> tuple[ResumeAnalyzer, AsyncMock, AsyncMock, AsyncMock]: + extractor = AsyncMock() + extractor.extract = AsyncMock(return_value=extracted) + chain = AsyncMock() + if analysis is not None: + chain.analyze = AsyncMock(return_value=analysis) + storage = AsyncMock() + analyzer = ResumeAnalyzer( + extractor=extractor, + chain=chain, + storage=storage, + analyzed_key_template="analyzed/resume/{resume_id}/summary.md", + ) + return analyzer, extractor, chain, storage + + +@pytest.mark.asyncio +async def test_happy_path_extracts_analyzes_and_saves() -> None: + analyzer, extractor, chain, storage = _make_analyzer( + extracted=ExtractedSource(text="hello", source_type="PDF"), + analysis=DocumentAnalysisResult( + summary="요약", tech_stack=["Go"], markdown="## 개요\nx" + ), + ) + result = await analyzer.analyze(resume_id=42, file_path="r/raw/1/a.pdf") + + extractor.extract.assert_awaited_once_with("r/raw/1/a.pdf") + chain.analyze.assert_awaited_once_with(text="hello", source_type="PDF") + storage.put_text.assert_awaited_once_with( + "analyzed/resume/42/summary.md", "## 개요\nx" + ) + + assert result.summary == "요약" + assert result.tech_stack == ["Go"] + assert result.document_path == "analyzed/resume/42/summary.md" + + +@pytest.mark.asyncio +async def test_empty_text_raises_domain_error_and_skips_llm() -> None: + analyzer, _, chain, storage = _make_analyzer( + extracted=ExtractedSource(text=" \n\t ", source_type="PDF"), + ) + with pytest.raises(ResumeAnalyzeError) as exc_info: + await analyzer.analyze(resume_id=1, file_path="empty.pdf") + + assert exc_info.value.code == "EMPTY_PDF_TEXT" + assert exc_info.value.retriable is False + chain.analyze.assert_not_called() + storage.put_text.assert_not_called() diff --git a/ai/tests/test_resume_consumer.py b/ai/tests/test_resume_consumer.py index ccf410da..8aa73c10 100644 --- a/ai/tests/test_resume_consumer.py +++ b/ai/tests/test_resume_consumer.py @@ -1,56 +1,168 @@ from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock -from ai_server.messaging.consumers.resume_consumer import ( - build_echo_callback, +import pytest + +from ai_server.analyzer.resume_analyzer import ( + ResumeAnalyzeError, + ResumeAnalysisResult, +) +from ai_server.messaging.consumers.resume_consumer import ResumeConsumer +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + ResumeAnalyzeRequest, ) -from ai_server.model.envelope import Envelope, MessageContext -from ai_server.model.messages.analyze import ResumeAnalyzeRequest -def _request_envelope() -> Envelope[ResumeAnalyzeRequest]: - return Envelope[ResumeAnalyzeRequest].model_validate( +def _request_envelope(message_id: str = "req-1", resume_id: int = 42) -> bytes: + env = Envelope[ResumeAnalyzeRequest].model_validate( { - "messageId": "req-1", + "messageId": message_id, "messageType": "analyze.resume", "version": "v1", "traceId": "trace-x", "publishedAt": datetime(2026, 5, 8, tzinfo=timezone.utc), "publisher": "core-server", - "payload": {"resumeId": 42, "s3Key": "resumes/raw/123/abc.pdf"}, + "payload": { + "resumeId": resume_id, + "filePath": "resumes/raw/123/abc.pdf", + }, "context": {"userId": 123}, } ) + return env.model_dump_json(by_alias=True).encode("utf-8") + + +def _incoming_message(body: bytes) -> MagicMock: + msg = MagicMock() + msg.body = body + msg.delivery_tag = 1 + # `async with message.process(...)` 형태 호출을 위해 async context manager 반환. + process_ctx = AsyncMock() + process_ctx.__aenter__.return_value = None + process_ctx.__aexit__.return_value = False + msg.process = MagicMock(return_value=process_ctx) + return msg + + +def _make_consumer( + analyzer: AsyncMock, + *, + idempotency: LruIdempotencyStore | None = None, +) -> tuple[ResumeConsumer, AsyncMock]: + publisher = AsyncMock() + consumer = ResumeConsumer( + analyzer=analyzer, + publisher=publisher, + idempotency=idempotency or LruIdempotencyStore(max_size=16), + callback_routing_key="callback.analysis", + ) + return consumer, publisher -def test_echo_callback_target_type_resume() -> None: - cb = build_echo_callback(_request_envelope()) - assert cb.target_type == "RESUME" - assert cb.target_id == 42 - assert cb.status == "ANALYZED" +def _captured_payload(publisher: AsyncMock) -> AnalysisCallbackPayload: + publisher.publish.assert_awaited_once() + kwargs = publisher.publish.await_args.kwargs + assert kwargs["message_type"] == "callback.analysis" + assert kwargs["routing_key"] == "callback.analysis" + payload = kwargs["payload"] + assert isinstance(payload, AnalysisCallbackPayload) + return payload + + +@pytest.mark.asyncio +async def test_happy_path_publishes_analyzed_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=ResumeAnalysisResult( + summary="요약", + tech_stack=["Python", "FastAPI"], + document_path="analyzed/resume/42/summary.md", + ) + ) + consumer, publisher = _make_consumer(analyzer) + + await consumer.handle(_incoming_message(_request_envelope())) + + analyzer.analyze.assert_awaited_once_with( + resume_id=42, file_path="resumes/raw/123/abc.pdf" + ) + payload = _captured_payload(publisher) + assert payload.status == "ANALYZED" + assert payload.target_type == "RESUME" + assert payload.target_id == 42 + assert payload.summary == "요약" + assert payload.tech_stack == ["Python", "FastAPI"] + assert payload.document_path == "analyzed/resume/42/summary.md" + assert payload.embedding_chunk_count == 0 + + +@pytest.mark.asyncio +async def test_domain_error_publishes_failed_callback_with_code() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + side_effect=ResumeAnalyzeError( + code="EMPTY_PDF_TEXT", + message="no text", + retriable=False, + ) + ) + consumer, publisher = _make_consumer(analyzer) + + await consumer.handle(_incoming_message(_request_envelope())) + + payload = _captured_payload(publisher) + assert payload.status == "FAILED" + assert payload.error_code == "EMPTY_PDF_TEXT" + assert payload.error_message == "no text" + assert payload.retriable is False + assert payload.summary is None + assert payload.document_path is None + + +@pytest.mark.asyncio +async def test_unexpected_error_publishes_failed_callback_as_retriable() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock(side_effect=RuntimeError("boom")) + consumer, publisher = _make_consumer(analyzer) + + await consumer.handle(_incoming_message(_request_envelope())) + + payload = _captured_payload(publisher) + assert payload.status == "FAILED" + assert payload.error_code == "UNEXPECTED" + assert payload.retriable is True + + +@pytest.mark.asyncio +async def test_idempotent_message_skips_analyzer_and_publish() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=ResumeAnalysisResult( + summary="요약", tech_stack=[], document_path="analyzed/resume/42/summary.md" + ) + ) + idem = LruIdempotencyStore(max_size=16) + consumer, publisher = _make_consumer(analyzer, idempotency=idem) + body = _request_envelope(message_id="dup-1") + await consumer.handle(_incoming_message(body)) + await consumer.handle(_incoming_message(body)) # 중복 메시지 -def test_echo_callback_includes_marker_summary() -> None: - cb = build_echo_callback(_request_envelope()) - assert cb.summary is not None - assert "ECHO" in cb.summary - assert cb.tech_stack == [] - assert cb.document_s3_key == "echo/resume/42/echo.md" - assert cb.embedding_chunk_count == 0 + assert analyzer.analyze.await_count == 1 + assert publisher.publish.await_count == 1 -def test_echo_callback_preserves_resume_id_in_s3_key() -> None: - env = _request_envelope() - env_dict = env.model_dump(by_alias=True) - env_dict["payload"]["resumeId"] = 7 - other = Envelope[ResumeAnalyzeRequest].model_validate(env_dict) - cb = build_echo_callback(other) - assert cb.target_id == 7 - assert cb.document_s3_key == "echo/resume/7/echo.md" +@pytest.mark.asyncio +async def test_parse_failure_raises_for_dlq() -> None: + analyzer = AsyncMock() + consumer, publisher = _make_consumer(analyzer) + bad = _incoming_message(b"not-json") + with pytest.raises(Exception): + await consumer.handle(bad) -def test_extract_context_passes_user_id() -> None: - env = _request_envelope() - cb = build_echo_callback(env) - # context 검증은 consumer entry에서 하지만 핸들러에 의존하지 않음. - # 여기서는 envelope.context가 그대로 보존되는지만 확인. - assert env.context.user_id == 123 + analyzer.analyze.assert_not_called() + publisher.publish.assert_not_called() diff --git a/ai/tests/test_settings.py b/ai/tests/test_settings.py index 98818e43..6baef0c6 100644 --- a/ai/tests/test_settings.py +++ b/ai/tests/test_settings.py @@ -20,7 +20,7 @@ def test_settings_defaults_for_messaging(monkeypatch: pytest.MonkeyPatch) -> Non s = Settings(_env_file=None) # type: ignore[call-arg] - assert s.rabbitmq_url == "amqp://stackup:stackup@localhost:5672/" + assert s.rabbitmq_url == "amqp://stackup:stackup@localhost:38050/" assert s.ai_queue_resume == "ai.analyze.resume" assert s.ai_queue_prefetch == 10 assert s.ai_callback_exchange == "stackup.ai-to-core" diff --git a/ai/tests/test_storage_factory.py b/ai/tests/test_storage_factory.py new file mode 100644 index 00000000..4369bdcc --- /dev/null +++ b/ai/tests/test_storage_factory.py @@ -0,0 +1,46 @@ +from unittest.mock import MagicMock, patch + +from ai_server.config.settings import Settings +from ai_server.storage.factory import build_storage +from ai_server.storage.local_fs import LocalFilesystemStorage +from ai_server.storage.s3 import S3Storage + + +def test_local_backend_returns_local_fs(tmp_path) -> None: + s = Settings( + _env_file=None, # type: ignore[call-arg] + storage_backend="local", + storage_local_root=str(tmp_path), + ) + storage = build_storage(s) + assert isinstance(storage, LocalFilesystemStorage) + assert storage.root == tmp_path.resolve() + + +def test_s3_backend_builds_with_endpoint_and_credentials() -> None: + s = Settings( + _env_file=None, # type: ignore[call-arg] + storage_backend="s3", + s3_bucket_name="stackup", + s3_endpoint_url="http://minio:9000", + s3_access_key="ak", + s3_secret_key="sk", + s3_region="us-east-1", + ) + with ( + patch("ai_server.storage.factory.boto3", create=True), + patch( + "ai_server.storage.s3.boto3.client", return_value=MagicMock() + ) as boto3_client, + ): + storage = build_storage(s) + + assert isinstance(storage, S3Storage) + assert storage.bucket == "stackup" + boto3_client.assert_called_once_with( + "s3", + endpoint_url="http://minio:9000", + aws_access_key_id="ak", + aws_secret_access_key="sk", + region_name="us-east-1", + ) diff --git a/ai/tests/test_storage_local_fs.py b/ai/tests/test_storage_local_fs.py new file mode 100644 index 00000000..9b2557fd --- /dev/null +++ b/ai/tests/test_storage_local_fs.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest + +from ai_server.storage.base import StorageKeyError +from ai_server.storage.local_fs import LocalFilesystemStorage + + +@pytest.mark.asyncio +async def test_put_then_get_roundtrip(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + await storage.put_bytes("nested/dir/file.bin", b"hello world") + assert await storage.get_bytes("nested/dir/file.bin") == b"hello world" + + +@pytest.mark.asyncio +async def test_put_text_writes_utf8(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + await storage.put_text("note.md", "# 한글") + assert await storage.get_bytes("note.md") == "# 한글".encode("utf-8") + + +@pytest.mark.asyncio +async def test_put_creates_parent_dirs(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + await storage.put_text("a/b/c/d.md", "x") + assert (tmp_path / "a" / "b" / "c" / "d.md").exists() + + +@pytest.mark.asyncio +async def test_exists_returns_false_for_missing(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + assert await storage.exists("never/written.md") is False + + +@pytest.mark.asyncio +async def test_rejects_absolute_key(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + with pytest.raises(StorageKeyError): + await storage.put_bytes("/etc/passwd", b"x") + + +@pytest.mark.asyncio +async def test_rejects_parent_traversal(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + with pytest.raises(StorageKeyError): + await storage.get_bytes("../outside.txt") + + +@pytest.mark.asyncio +async def test_rejects_empty_key(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + with pytest.raises(StorageKeyError): + await storage.put_bytes("", b"x") + + +@pytest.mark.asyncio +async def test_exists_returns_false_on_invalid_key(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + assert await storage.exists("../escape.md") is False diff --git a/ai/tests/test_storage_s3.py b/ai/tests/test_storage_s3.py new file mode 100644 index 00000000..11e94522 --- /dev/null +++ b/ai/tests/test_storage_s3.py @@ -0,0 +1,115 @@ +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ClientError + +from ai_server.storage.base import StorageKeyError +from ai_server.storage.s3 import S3Storage + + +def _make_storage() -> tuple[S3Storage, MagicMock]: + client = MagicMock() + storage = S3Storage(bucket="test-bucket", client=client) + return storage, client + + +@pytest.mark.asyncio +async def test_get_bytes_calls_s3_get_object() -> None: + storage, client = _make_storage() + body = MagicMock() + body.read.return_value = b"hello" + client.get_object.return_value = {"Body": body} + + out = await storage.get_bytes("a/b/c.pdf") + + client.get_object.assert_called_once_with(Bucket="test-bucket", Key="a/b/c.pdf") + body.read.assert_called_once() + assert out == b"hello" + + +@pytest.mark.asyncio +async def test_put_bytes_passes_content_type() -> None: + storage, client = _make_storage() + await storage.put_bytes("k.bin", b"x", content_type="application/octet-stream") + + client.put_object.assert_called_once() + kwargs = client.put_object.call_args.kwargs + assert kwargs["Bucket"] == "test-bucket" + assert kwargs["Key"] == "k.bin" + assert kwargs["Body"] == b"x" + assert kwargs["ContentType"] == "application/octet-stream" + + +@pytest.mark.asyncio +async def test_put_bytes_omits_content_type_when_none() -> None: + storage, client = _make_storage() + await storage.put_bytes("k.bin", b"x") + + kwargs = client.put_object.call_args.kwargs + assert "ContentType" not in kwargs + + +@pytest.mark.asyncio +async def test_put_text_encodes_utf8_with_markdown_content_type() -> None: + storage, client = _make_storage() + await storage.put_text("doc.md", "# 한글") + + kwargs = client.put_object.call_args.kwargs + assert kwargs["Body"] == "# 한글".encode("utf-8") + assert "markdown" in kwargs["ContentType"] + + +@pytest.mark.asyncio +async def test_exists_returns_true_when_head_succeeds() -> None: + storage, client = _make_storage() + client.head_object.return_value = {} + assert await storage.exists("doc.md") is True + client.head_object.assert_called_once_with(Bucket="test-bucket", Key="doc.md") + + +@pytest.mark.asyncio +async def test_exists_returns_false_on_404() -> None: + storage, client = _make_storage() + client.head_object.side_effect = ClientError( + {"Error": {"Code": "404", "Message": "Not Found"}}, + "HeadObject", + ) + assert await storage.exists("missing.md") is False + + +@pytest.mark.asyncio +async def test_exists_reraises_unexpected_client_error() -> None: + storage, client = _make_storage() + client.head_object.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "nope"}}, + "HeadObject", + ) + with pytest.raises(ClientError): + await storage.exists("forbidden.md") + + +@pytest.mark.asyncio +async def test_rejects_absolute_key() -> None: + storage, client = _make_storage() + with pytest.raises(StorageKeyError): + await storage.put_bytes("/etc/passwd", b"x") + client.put_object.assert_not_called() + + +@pytest.mark.asyncio +async def test_rejects_empty_key() -> None: + storage, _ = _make_storage() + with pytest.raises(StorageKeyError): + await storage.get_bytes("") + + +@pytest.mark.asyncio +async def test_exists_false_on_invalid_key() -> None: + storage, client = _make_storage() + assert await storage.exists("/abs") is False + client.head_object.assert_not_called() + + +def test_constructor_requires_bucket() -> None: + with pytest.raises(ValueError): + S3Storage(bucket="", client=MagicMock())