diff --git a/app/core/exceptions.py b/app/core/exceptions.py index ceb3c01..0aab76d 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -158,3 +158,32 @@ class AIReportGenerationError(MandacaError): def __init__(self, detail: str) -> None: super().__init__(f"Falha ao gerar relatório IA: {detail}") self.detail = detail + + +# --------------------------------------------------------------------------- +# Exceções de Auto Apply (auto_apply_service) +# --------------------------------------------------------------------------- + + +class FieldNotAllowedError(MandacaError): + def __init__(self, campo: str) -> None: + super().__init__(f"Campo não permitido para alteração automática: {campo}") + self.campo = campo + + +class InvalidFieldValueError(MandacaError): + def __init__(self, campo: str, detail: str) -> None: + super().__init__(f"Valor inválido para o campo {campo}: {detail}") + self.campo = campo + self.detail = detail + + +class AutoApplyPersistenceError(MandacaError): + def __init__(self) -> None: + super().__init__("Falha ao aplicar alteração no banco de dados.") + + +class SuggestionExtractionError(MandacaError): + def __init__(self, detail: str) -> None: + super().__init__(f"Falha ao extrair sugestões do relatório: {detail}") + self.detail = detail diff --git a/app/main.py b/app/main.py index 0d1b36c..cbe23d0 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ AudioServiceTimeoutError, AudioTooLargeError, AudioTranscriptionError, + AutoApplyPersistenceError, BusinessContextNotFoundError, ChatRateLimitError, ChatServiceConnectionError, @@ -19,9 +20,13 @@ ChatServiceTimeoutError, DuplicateEnterpriseNameError, EnterpriseNotFoundError, + FieldNotAllowedError, GeocodingUnavailableError, InvalidContextDataError, + InvalidFieldValueError, MandacaError, + MenuNotFoundError, + SuggestionExtractionError, UnsupportedAudioFormatError, UserAlreadyHasEnterpriseError, UserAlreadyLinkedError, @@ -29,6 +34,7 @@ ) from app.routers import ( assessments, + auto_apply, business_context, chat, enterprises, @@ -52,13 +58,19 @@ app.include_router(menus.router) app.include_router(business_context.router) app.include_router(reports.router) +app.include_router(auto_apply.router) # --------------------------------------------------------------------------- # Handlers de exceções de domínio — conversão domínio → HTTP única e central # --------------------------------------------------------------------------- -_NOT_FOUND_TYPES = (EnterpriseNotFoundError, UserNotFoundError, AIReportNotFoundError) +_NOT_FOUND_TYPES = ( + EnterpriseNotFoundError, + UserNotFoundError, + AIReportNotFoundError, + MenuNotFoundError, +) _BAD_REQUEST_TYPES = ( DuplicateEnterpriseNameError, UserAlreadyHasEnterpriseError, @@ -70,7 +82,9 @@ ChatServiceConnectionError, ChatServiceError, AIReportGenerationError, + SuggestionExtractionError, ) +_INTERNAL_ERROR_TYPES = (AutoApplyPersistenceError,) async def _handle_400(request: Request, exc: MandacaError) -> JSONResponse: @@ -109,6 +123,13 @@ async def _handle_504(request: Request, exc: MandacaError) -> JSONResponse: return JSONResponse(status_code=504, content={"detail": str(exc)}) +async def _handle_500(request: Request, exc: MandacaError) -> JSONResponse: + return JSONResponse( + status_code=500, + content={"detail": "Erro interno do servidor."}, + ) + + def _register_handlers(fastapi_app: FastAPI) -> None: for exc_class in _NOT_FOUND_TYPES: fastapi_app.add_exception_handler(exc_class, _handle_404) @@ -126,6 +147,10 @@ def _register_handlers(fastapi_app: FastAPI) -> None: fastapi_app.add_exception_handler(ChatServiceTimeoutError, _handle_504) fastapi_app.add_exception_handler(BusinessContextNotFoundError, _handle_404) fastapi_app.add_exception_handler(InvalidContextDataError, _handle_422) + fastapi_app.add_exception_handler(FieldNotAllowedError, _handle_422) + fastapi_app.add_exception_handler(InvalidFieldValueError, _handle_422) + for exc_class in _INTERNAL_ERROR_TYPES: + fastapi_app.add_exception_handler(exc_class, _handle_500) _register_handlers(app) diff --git a/app/routers/auto_apply.py b/app/routers/auto_apply.py new file mode 100644 index 0000000..6fc9a05 --- /dev/null +++ b/app/routers/auto_apply.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from app.core.session import get_db +from app.schemas.auto_apply import AutoApplyRequest, AutoApplyResponse +from app.services.auto_apply_service import AutoApplyService + +router = APIRouter(prefix="/auto-apply", tags=["auto-apply"]) + + +def get_auto_apply_service() -> AutoApplyService: + return AutoApplyService() + + +@router.post( + "", + response_model=AutoApplyResponse, + status_code=status.HTTP_200_OK, +) +async def auto_apply( + payload: AutoApplyRequest, + db: Session = Depends(get_db), + service: AutoApplyService = Depends(get_auto_apply_service), +) -> AutoApplyResponse: + return service.apply(payload, db) diff --git a/app/routers/reports.py b/app/routers/reports.py index 3943656..945d64b 100644 --- a/app/routers/reports.py +++ b/app/routers/reports.py @@ -5,7 +5,9 @@ from app.core.session import get_db from app.models.report import AIReport +from app.schemas.auto_apply import ReportAutoApplyResponse from app.schemas.reports import AIReportDetail, AIReportSummary +from app.services.report_auto_apply_service import ReportAutoApplyService from app.services.report_service import ReportService router = APIRouter(prefix="/reports", tags=["reports"]) @@ -15,6 +17,10 @@ def get_report_service() -> ReportService: return ReportService() +def get_report_auto_apply_service() -> ReportAutoApplyService: + return ReportAutoApplyService() + + @router.post( "/generate/{empresa_id}", response_model=AIReportDetail, @@ -44,3 +50,16 @@ async def get_report( service: ReportService = Depends(get_report_service), ) -> AIReport: return service.get_by_id(report_id, db) + + +@router.post( + "/{report_id}/auto-apply", + response_model=ReportAutoApplyResponse, + status_code=status.HTTP_200_OK, +) +async def auto_apply_from_report( + report_id: UUID, + db: Session = Depends(get_db), + service: ReportAutoApplyService = Depends(get_report_auto_apply_service), +) -> ReportAutoApplyResponse: + return service.apply_from_report(report_id, db) diff --git a/app/schemas/auto_apply.py b/app/schemas/auto_apply.py new file mode 100644 index 0000000..f40fa1b --- /dev/null +++ b/app/schemas/auto_apply.py @@ -0,0 +1,54 @@ +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, model_validator + + +class AutoApplyTarget(str, Enum): + ENTERPRISE = "enterprise" + MENU_ITEM = "menu_item" + + +class AutoApplyRequest(BaseModel): + enterprise_id: UUID + target: AutoApplyTarget + menu_item_id: UUID | None = None + campo_para_alterar: str + novo_valor: str + + @model_validator(mode="after") + def _menu_item_id_required_when_menu(self) -> "AutoApplyRequest": + if self.target == AutoApplyTarget.MENU_ITEM and self.menu_item_id is None: + raise ValueError("menu_item_id é obrigatório quando target=menu_item") + return self + + +class SuggestionStatus(str, Enum): + APPLIED = "aplicado" + REJECTED = "rejeitado" + + +class AutoApplyResponse(BaseModel): + campo_alterado: str + status: SuggestionStatus = SuggestionStatus.APPLIED + + +class AutoApplySuggestion(BaseModel): + target: AutoApplyTarget + menu_item_id: UUID | None = None + campo_para_alterar: str + novo_valor: str + + +class AutoApplySuggestionResult(BaseModel): + sugestao: AutoApplySuggestion + status: SuggestionStatus + erro: str | None = None + + +class ReportAutoApplyResponse(BaseModel): + report_id: UUID + total: int + aplicadas: int + rejeitadas: int + resultados: list[AutoApplySuggestionResult] diff --git a/app/services/auto_apply_service.py b/app/services/auto_apply_service.py new file mode 100644 index 0000000..fd0035f --- /dev/null +++ b/app/services/auto_apply_service.py @@ -0,0 +1,134 @@ +import re +from datetime import time +from decimal import Decimal, InvalidOperation +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.exceptions import ( + AutoApplyPersistenceError, + EnterpriseNotFoundError, + FieldNotAllowedError, + InvalidFieldValueError, + MenuNotFoundError, +) +from app.models.enterprise import Enterprise +from app.models.menu import Menu +from app.schemas.auto_apply import ( + AutoApplyRequest, + AutoApplyResponse, + AutoApplyTarget, + SuggestionStatus, +) + +# Whitelist: campo lógico (enviado pela IA) -> coluna real no modelo. +# horario_funcionamento é caso especial: gera duas colunas (hora_abrir + hora_fechar). +_ENTERPRISE_FIELD_MAP: dict[str, str | None] = { + "historia": "historia", + "telefone": "telefone", + "endereco": "endereco", + "horario_funcionamento": None, +} + +# Inversão intencional: o modelo Menu usa nomes históricos de coluna. +# "descricao" armazena o nome exibido do item; "historia" armazena a descrição longa. +_MENU_FIELD_MAP: dict[str, str] = { + "nome": "descricao", + "descricao": "historia", + "preco": "preco", +} + +_HORARIO_PATTERN = re.compile(r"^(\d{2}:\d{2})-(\d{2}:\d{2})$") + + +class AutoApplyService: + def apply( + self, payload: AutoApplyRequest, db: Session, *, commit: bool = True + ) -> AutoApplyResponse: + if payload.target == AutoApplyTarget.ENTERPRISE: + self._apply_to_enterprise(payload, db) + else: + self._apply_to_menu_item(payload, db) + + if commit: + self._persist(db) + + return AutoApplyResponse( + campo_alterado=payload.campo_para_alterar, + status=SuggestionStatus.APPLIED, + ) + + def _apply_to_enterprise(self, payload: AutoApplyRequest, db: Session) -> None: + if payload.campo_para_alterar not in _ENTERPRISE_FIELD_MAP: + raise FieldNotAllowedError(payload.campo_para_alterar) + + enterprise = self._get_enterprise(payload.enterprise_id, db) + + if payload.campo_para_alterar == "horario_funcionamento": + hora_abrir, hora_fechar = self._parse_horario(payload.novo_valor) + enterprise.hora_abrir = hora_abrir + enterprise.hora_fechar = hora_fechar + return + + coluna = _ENTERPRISE_FIELD_MAP[payload.campo_para_alterar] + setattr(enterprise, coluna, payload.novo_valor) + + def _apply_to_menu_item(self, payload: AutoApplyRequest, db: Session) -> None: + if payload.campo_para_alterar not in _MENU_FIELD_MAP: + raise FieldNotAllowedError(payload.campo_para_alterar) + + menu = self._get_menu(payload.menu_item_id, db) + coluna = _MENU_FIELD_MAP[payload.campo_para_alterar] + valor = self._coerce_menu_value(payload.campo_para_alterar, payload.novo_valor) + setattr(menu, coluna, valor) + + def _get_enterprise(self, enterprise_id: UUID, db: Session) -> Enterprise: + enterprise = db.get(Enterprise, enterprise_id) + if not enterprise: + raise EnterpriseNotFoundError(enterprise_id) + return enterprise + + def _get_menu(self, menu_item_id: UUID | None, db: Session) -> Menu: + menu = db.execute( + select(Menu).where( + Menu.id_cardapio == menu_item_id, + Menu.status.is_(True), + ) + ).scalar_one_or_none() + if not menu: + raise MenuNotFoundError(menu_item_id) + return menu + + def _coerce_menu_value(self, campo_logico: str, novo_valor: str) -> Any: + if campo_logico == "preco": + try: + return Decimal(novo_valor) + except InvalidOperation as exc: + raise InvalidFieldValueError(campo_logico, "preço inválido") from exc + return novo_valor + + def _parse_horario(self, valor: str) -> tuple[time, time]: + match = _HORARIO_PATTERN.match(valor) + if not match: + raise InvalidFieldValueError( + "horario_funcionamento", + "formato esperado HH:MM-HH:MM", + ) + try: + hora_abrir = time.fromisoformat(match.group(1)) + hora_fechar = time.fromisoformat(match.group(2)) + except ValueError as exc: + raise InvalidFieldValueError( + "horario_funcionamento", + "horário inválido", + ) from exc + return hora_abrir, hora_fechar + + def _persist(self, db: Session) -> None: + try: + db.commit() + except Exception as exc: + db.rollback() + raise AutoApplyPersistenceError() from exc diff --git a/app/services/report_auto_apply_service.py b/app/services/report_auto_apply_service.py new file mode 100644 index 0000000..18fd771 --- /dev/null +++ b/app/services/report_auto_apply_service.py @@ -0,0 +1,197 @@ +import json +from uuid import UUID + +from google import genai +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.exceptions import ( + AIReportNotFoundError, + EnterpriseNotFoundError, + MandacaError, + SuggestionExtractionError, +) +from app.models.enterprise import Enterprise +from app.models.menu import Menu +from app.models.report import AIReport +from app.schemas.auto_apply import ( + AutoApplyRequest, + AutoApplySuggestion, + AutoApplySuggestionResult, + ReportAutoApplyResponse, + SuggestionStatus, +) +from app.services.auto_apply_service import AutoApplyService + +_SYSTEM_PROMPT = ( + "Você analisa um relatório de melhorias de um restaurante e extrai mudanças " + "concretas e acionáveis para campos específicos da base de dados. " + "Você recebe o relatório E o estado atual dos dados (telefone, endereço, " + "horário, história, itens do cardápio com preço). Use o estado atual como " + "ponto de partida para INTERPRETAR sugestões vagas em valores concretos.\n\n" + "REGRAS DE INTERPRETAÇÃO (seja proativo quando o relatório indicar direção):\n" + "- Se o relatório disser 'reduzir um pouco o preço', sugira uma redução de " + "10% a 20% sobre o preço atual e devolva o valor calculado.\n" + "- Se disser 'aumentar levemente', sugira aumento de 5% a 15%.\n" + "- Se disser 'preço irrealista' ou 'muito alto/baixo' SEM faixa explícita, " + "use bom senso de mercado para itens similares no Nordeste.\n" + "- Se disser 'abrir mais cedo', sugira 30 a 60 minutos antes do hora_abrir " + "atual. 'Fechar mais tarde' equivalente.\n" + "- Se disser 'reescrever história' ou 'descrição mais convidativa', " + "proponha um texto curto (até 250 chars) baseado na especialidade da empresa.\n" + "- Se disser 'mudar nome do item', proponha um nome mais atrativo a partir " + "da descrição existente.\n\n" + "REGRAS RÍGIDAS DE NÃO-INVENÇÃO (se violar, descarte a sugestão):\n" + "- Telefone: SÓ inclua se o relatório trouxer um número específico literal. " + "Caso contrário NÃO sugira esse campo.\n" + "- Endereço: idem — só com endereço real explícito no texto.\n\n" + "Whitelist de campos permitidos:\n" + "- target=enterprise: 'historia', 'telefone', 'endereco', " + "'horario_funcionamento' (formato HH:MM-HH:MM)\n" + "- target=menu_item: 'nome' (nome exibido do item), " + "'descricao' (texto descritivo longo do item), " + "'preco' (string com decimal, ex: '32.50')\n\n" + "Para sugestões em itens de cardápio, use APENAS os IDs (menu_item_id) " + "fornecidos no contexto. Nunca invente IDs.\n" + "Retorne APENAS JSON conforme o schema fornecido." +) + + +class _SuggestionsLLMOutput(BaseModel): + sugestoes: list[AutoApplySuggestion] + + +class ReportAutoApplyService: + def __init__( + self, + gemini_client: genai.Client | None = None, + auto_apply_service: AutoApplyService | None = None, + ) -> None: + self._gemini_client = gemini_client or genai.Client(api_key=settings.gemini_api_key) + self._auto_apply_service = auto_apply_service or AutoApplyService() + + def apply_from_report(self, report_id: UUID, db: Session) -> ReportAutoApplyResponse: + report = self._load_report(report_id, db) + enterprise = self._load_enterprise(report.empresa_id, db) + menu_items = self._load_menu_items(report.empresa_id, db) + sugestoes = self._extract_suggestions(report, enterprise, menu_items) + + resultados = [self._apply_one(report.empresa_id, s, db) for s in sugestoes] + aplicadas = sum(1 for r in resultados if r.status == SuggestionStatus.APPLIED) + + if aplicadas > 0: + db.commit() + + return ReportAutoApplyResponse( + report_id=report_id, + total=len(resultados), + aplicadas=aplicadas, + rejeitadas=len(resultados) - aplicadas, + resultados=resultados, + ) + + def _load_report(self, report_id: UUID, db: Session) -> AIReport: + report = db.get(AIReport, report_id) + if not report: + raise AIReportNotFoundError(report_id) + return report + + def _load_enterprise(self, empresa_id: UUID, db: Session) -> Enterprise: + enterprise = db.get(Enterprise, empresa_id) + if not enterprise: + raise EnterpriseNotFoundError(empresa_id) + return enterprise + + def _load_menu_items(self, empresa_id: UUID, db: Session) -> list[Menu]: + return list( + db.execute( + select(Menu).where( + Menu.empresa_id == empresa_id, + Menu.status.is_(True), + ) + ) + .scalars() + .all() + ) + + def _extract_suggestions( + self, + report: AIReport, + enterprise: Enterprise, + menu_items: list[Menu], + ) -> list[AutoApplySuggestion]: + contexto = { + "relatorio": { + "melhorias_resumo": report.melhorias_resumo, + "melhorias_detalhado": report.melhorias_detalhado, + "recomendacoes_resumo": report.recomendacoes_resumo, + "recomendacoes_detalhado": report.recomendacoes_detalhado, + }, + "estado_atual_empresa": { + "nome": enterprise.nome, + "especialidade": enterprise.especialidade, + "historia": enterprise.historia, + "telefone": enterprise.telefone, + "endereco": enterprise.endereco, + "hora_abrir": ( + enterprise.hora_abrir.isoformat() if enterprise.hora_abrir else None + ), + "hora_fechar": ( + enterprise.hora_fechar.isoformat() if enterprise.hora_fechar else None + ), + }, + "itens_cardapio": [ + { + "menu_item_id": str(m.id_cardapio), + "nome_atual": m.descricao, + "descricao_atual": m.historia, + "preco_atual": str(m.preco), + "categoria": m.categoria.value if m.categoria else None, + } + for m in menu_items + ], + } + + try: + response = self._gemini_client.models.generate_content( + model="gemini-2.5-flash", + contents=json.dumps(contexto, ensure_ascii=False), + config={ + "system_instruction": _SYSTEM_PROMPT, + "response_mime_type": "application/json", + "response_json_schema": _SuggestionsLLMOutput.model_json_schema(), + "temperature": 0.2, + }, + ) + parsed = _SuggestionsLLMOutput.model_validate_json(response.text) + return parsed.sugestoes + except Exception as exc: + raise SuggestionExtractionError(str(exc)) from exc + + def _apply_one( + self, + empresa_id: UUID, + sugestao: AutoApplySuggestion, + db: Session, + ) -> AutoApplySuggestionResult: + try: + request = AutoApplyRequest( + enterprise_id=empresa_id, + target=sugestao.target, + menu_item_id=sugestao.menu_item_id, + campo_para_alterar=sugestao.campo_para_alterar, + novo_valor=sugestao.novo_valor, + ) + self._auto_apply_service.apply(request, db, commit=False) + return AutoApplySuggestionResult( + sugestao=sugestao, + status=SuggestionStatus.APPLIED, + ) + except (MandacaError, ValueError) as exc: + return AutoApplySuggestionResult( + sugestao=sugestao, + status=SuggestionStatus.REJECTED, + erro=str(exc), + ) diff --git a/app/services/report_service.py b/app/services/report_service.py index 1e22d17..81a1177 100644 --- a/app/services/report_service.py +++ b/app/services/report_service.py @@ -24,7 +24,17 @@ "Analise o contexto do negócio fornecido e gere um relatório estruturado em JSON com três " "seções: pontos_positivos, melhorias e recomendacoes. " "Cada seção deve ter duas versões: resumo (até 100 palavras) e detalhado (até 400 palavras). " - "Escreva em português brasileiro. Seja objetivo, construtivo e específico para o negócio. " + "Escreva em português brasileiro. Seja objetivo, construtivo e específico para o negócio.\n\n" + "DIRETRIZES PARA SUGESTÕES ACIONÁVEIS:\n" + "Sempre que possível, traga sugestões CONCRETAS com valores específicos a partir do contexto:\n" + "- Ao sugerir alteração de preço de um item, indique uma faixa de valor recomendada " + "(ex: 'reduzir o preço de R$ 89,99 para algo entre R$ 6,00 e R$ 9,00').\n" + "- Ao sugerir mudança de horário, proponha um intervalo concreto " + "(ex: 'abrir às 06:30 para atender o público do café da manhã').\n" + "- Ao sugerir nova história ou descrição de item, proponha um texto curto inspirador " + "que o estabelecimento poderia adotar.\n" + "Para dados que não podem ser inventados (telefone real, endereço físico real), " + "indique apenas que precisam ser corrigidos pelo usuário, sem propor valor.\n\n" "Retorne somente JSON compatível com o schema fornecido." ) diff --git a/tests/test_auto_apply_router.py b/tests/test_auto_apply_router.py new file mode 100644 index 0000000..f4b85eb --- /dev/null +++ b/tests/test_auto_apply_router.py @@ -0,0 +1,127 @@ +import uuid +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from app.core.exceptions import ( + EnterpriseNotFoundError, + FieldNotAllowedError, + InvalidFieldValueError, +) +from app.main import app +from app.routers.auto_apply import get_auto_apply_service +from app.schemas.auto_apply import AutoApplyResponse, SuggestionStatus + +FAKE_ENTERPRISE_ID = uuid.UUID("00000000-0000-0000-0000-000000000001") + + +@pytest.fixture(autouse=True) +def _clear_overrides(): + yield + app.dependency_overrides.clear() + + +def _make_client(mock_service: MagicMock) -> TestClient: + app.dependency_overrides[get_auto_apply_service] = lambda: mock_service + return TestClient(app, raise_server_exceptions=False) + + +def test_given_valid_payload_when_auto_apply_then_returns_200(): + # GIVEN + mock_service = MagicMock() + mock_service.apply.return_value = AutoApplyResponse( + campo_alterado="historia", + status=SuggestionStatus.APPLIED, + ) + client = _make_client(mock_service) + + # WHEN + response = client.post( + "/auto-apply", + json={ + "enterprise_id": str(FAKE_ENTERPRISE_ID), + "target": "enterprise", + "campo_para_alterar": "historia", + "novo_valor": "Nova historia", + }, + ) + + # THEN + assert response.status_code == 200 + body = response.json() + assert body["campo_alterado"] == "historia" + assert body["status"] == "aplicado" + + +def test_given_missing_enterprise_when_auto_apply_then_returns_404(): + # GIVEN + mock_service = MagicMock() + mock_service.apply.side_effect = EnterpriseNotFoundError(FAKE_ENTERPRISE_ID) + client = _make_client(mock_service) + + # WHEN + response = client.post( + "/auto-apply", + json={ + "enterprise_id": str(FAKE_ENTERPRISE_ID), + "target": "enterprise", + "campo_para_alterar": "historia", + "novo_valor": "x", + }, + ) + + # THEN + assert response.status_code == 404 + + +def test_given_forbidden_field_when_auto_apply_then_returns_422(): + # GIVEN + mock_service = MagicMock() + mock_service.apply.side_effect = FieldNotAllowedError("owner_id") + client = _make_client(mock_service) + + # WHEN + response = client.post( + "/auto-apply", + json={ + "enterprise_id": str(FAKE_ENTERPRISE_ID), + "target": "enterprise", + "campo_para_alterar": "owner_id", + "novo_valor": "x", + }, + ) + + # THEN + assert response.status_code == 422 + + +def test_given_invalid_value_when_auto_apply_then_returns_422(): + # GIVEN + mock_service = MagicMock() + mock_service.apply.side_effect = InvalidFieldValueError("preco", "invalido") + client = _make_client(mock_service) + + # WHEN + response = client.post( + "/auto-apply", + json={ + "enterprise_id": str(FAKE_ENTERPRISE_ID), + "target": "menu_item", + "menu_item_id": str(uuid.uuid4()), + "campo_para_alterar": "preco", + "novo_valor": "abc", + }, + ) + + # THEN + assert response.status_code == 422 + + +def test_given_invalid_json_when_auto_apply_then_returns_422(): + # WHEN + client = TestClient(app, raise_server_exceptions=False) + response = client.post("/auto-apply", json={"enterprise_id": "not-a-uuid"}) + + # THEN + assert response.status_code == 422 diff --git a/tests/test_auto_apply_service.py b/tests/test_auto_apply_service.py new file mode 100644 index 0000000..d6e4261 --- /dev/null +++ b/tests/test_auto_apply_service.py @@ -0,0 +1,367 @@ +""" +Testes unitários para AutoApplyService. + +Foco: validação de whitelist, mapeamento campo lógico → coluna real, +parsing de horário e tratamento de erros do banco. +Session do SQLAlchemy completamente mockada — sem banco real. +""" + +import uuid +from datetime import time +from decimal import Decimal +from unittest.mock import MagicMock + +import pytest + +from app.core.exceptions import ( + AutoApplyPersistenceError, + EnterpriseNotFoundError, + FieldNotAllowedError, + InvalidFieldValueError, + MenuNotFoundError, +) +from app.models.enterprise import Enterprise +from app.models.menu import CategoriaCardapio, Menu +from app.schemas.auto_apply import AutoApplyRequest, AutoApplyTarget +from app.services.auto_apply_service import AutoApplyService + +FAKE_ENTERPRISE_ID = uuid.uuid4() +FAKE_MENU_ID = uuid.uuid4() + + +def _make_enterprise() -> Enterprise: + return Enterprise( + id_empresa=FAKE_ENTERPRISE_ID, + nome="Empresa Teste", + usuario_id=uuid.uuid4(), + ) + + +def _make_menu() -> Menu: + return Menu( + id_cardapio=FAKE_MENU_ID, + descricao="Item teste", + historia=None, + preco=Decimal("10.00"), + categoria=CategoriaCardapio.PRATO_PRINCIPAL, + status=True, + empresa_id=FAKE_ENTERPRISE_ID, + ) + + +def _mock_db_with(record) -> MagicMock: + db = MagicMock() + db.get.return_value = record + execute_result = MagicMock() + execute_result.scalar_one_or_none.return_value = record + db.execute.return_value = execute_result + return db + + +# --------------------------------------------------------------------------- +# Enterprise +# --------------------------------------------------------------------------- + + +def test_given_valid_historia_when_applied_then_updates_enterprise() -> None: + # GIVEN + enterprise = _make_enterprise() + db = _mock_db_with(enterprise) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.ENTERPRISE, + campo_para_alterar="historia", + novo_valor="Nova história", + ) + + # WHEN + response = AutoApplyService().apply(payload, db) + + # THEN + assert enterprise.historia == "Nova história" + assert response.campo_alterado == "historia" + assert response.status == "aplicado" + db.commit.assert_called_once() + + +def test_given_commit_false_when_applied_then_skips_commit() -> None: + # GIVEN + enterprise = _make_enterprise() + db = _mock_db_with(enterprise) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.ENTERPRISE, + campo_para_alterar="historia", + novo_valor="Sem commit", + ) + + # WHEN + response = AutoApplyService().apply(payload, db, commit=False) + + # THEN + assert enterprise.historia == "Sem commit" + assert response.campo_alterado == "historia" + db.commit.assert_not_called() + + +def test_given_telefone_when_applied_then_updates_enterprise() -> None: + # GIVEN + enterprise = _make_enterprise() + db = _mock_db_with(enterprise) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.ENTERPRISE, + campo_para_alterar="telefone", + novo_valor="81999999999", + ) + + # WHEN + AutoApplyService().apply(payload, db) + + # THEN + assert enterprise.telefone == "81999999999" + + +def test_given_horario_when_applied_then_parses_and_sets_both_columns() -> None: + # GIVEN + enterprise = _make_enterprise() + db = _mock_db_with(enterprise) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.ENTERPRISE, + campo_para_alterar="horario_funcionamento", + novo_valor="08:00-18:30", + ) + + # WHEN + AutoApplyService().apply(payload, db) + + # THEN + assert enterprise.hora_abrir == time(8, 0) + assert enterprise.hora_fechar == time(18, 30) + + +# --------------------------------------------------------------------------- +# Menu +# --------------------------------------------------------------------------- + + +def test_given_valid_preco_when_applied_then_updates_menu() -> None: + # GIVEN + menu = _make_menu() + db = _mock_db_with(menu) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.MENU_ITEM, + menu_item_id=FAKE_MENU_ID, + campo_para_alterar="preco", + novo_valor="42.50", + ) + + # WHEN + response = AutoApplyService().apply(payload, db) + + # THEN + assert menu.preco == Decimal("42.50") + assert response.campo_alterado == "preco" + + +def test_given_logical_nome_when_applied_then_writes_to_descricao_column() -> None: + # GIVEN + menu = _make_menu() + db = _mock_db_with(menu) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.MENU_ITEM, + menu_item_id=FAKE_MENU_ID, + campo_para_alterar="nome", + novo_valor="Novo nome do prato", + ) + + # WHEN + AutoApplyService().apply(payload, db) + + # THEN + assert menu.descricao == "Novo nome do prato" + + +def test_given_logical_descricao_when_applied_then_writes_to_historia_column() -> None: + # GIVEN + menu = _make_menu() + db = _mock_db_with(menu) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.MENU_ITEM, + menu_item_id=FAKE_MENU_ID, + campo_para_alterar="descricao", + novo_valor="Descrição longa do item", + ) + + # WHEN + AutoApplyService().apply(payload, db) + + # THEN + assert menu.historia == "Descrição longa do item" + + +# --------------------------------------------------------------------------- +# Whitelist e validações +# --------------------------------------------------------------------------- + + +def test_given_field_outside_whitelist_when_applied_then_raises_422() -> None: + # GIVEN + enterprise = _make_enterprise() + db = _mock_db_with(enterprise) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.ENTERPRISE, + campo_para_alterar="owner_id", + novo_valor="qualquer", + ) + + # WHEN / THEN + with pytest.raises(FieldNotAllowedError): + AutoApplyService().apply(payload, db) + db.commit.assert_not_called() + + +def test_given_menu_field_outside_whitelist_when_applied_then_raises_422() -> None: + # GIVEN + menu = _make_menu() + db = _mock_db_with(menu) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.MENU_ITEM, + menu_item_id=FAKE_MENU_ID, + campo_para_alterar="categoria", + novo_valor="bebida", + ) + + # WHEN / THEN + with pytest.raises(FieldNotAllowedError): + AutoApplyService().apply(payload, db) + + +def test_given_target_menu_without_id_when_validated_then_raises_422() -> None: + # GIVEN / WHEN / THEN + with pytest.raises(ValueError): + AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.MENU_ITEM, + campo_para_alterar="preco", + novo_valor="10.00", + ) + + +# --------------------------------------------------------------------------- +# Not found +# --------------------------------------------------------------------------- + + +def test_given_missing_enterprise_when_applied_then_raises_404() -> None: + # GIVEN + db = _mock_db_with(None) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.ENTERPRISE, + campo_para_alterar="historia", + novo_valor="x", + ) + + # WHEN / THEN + with pytest.raises(EnterpriseNotFoundError): + AutoApplyService().apply(payload, db) + + +def test_given_missing_menu_item_when_applied_then_raises_404() -> None: + # GIVEN + db = _mock_db_with(None) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.MENU_ITEM, + menu_item_id=FAKE_MENU_ID, + campo_para_alterar="preco", + novo_valor="10.00", + ) + + # WHEN / THEN + with pytest.raises(MenuNotFoundError): + AutoApplyService().apply(payload, db) + + +# --------------------------------------------------------------------------- +# Coerção de valores +# --------------------------------------------------------------------------- + + +def test_given_invalid_preco_value_when_applied_then_raises_422() -> None: + # GIVEN + menu = _make_menu() + db = _mock_db_with(menu) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.MENU_ITEM, + menu_item_id=FAKE_MENU_ID, + campo_para_alterar="preco", + novo_valor="abc", + ) + + # WHEN / THEN + with pytest.raises(InvalidFieldValueError): + AutoApplyService().apply(payload, db) + + +def test_given_invalid_horario_format_when_applied_then_raises_422() -> None: + # GIVEN + enterprise = _make_enterprise() + db = _mock_db_with(enterprise) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.ENTERPRISE, + campo_para_alterar="horario_funcionamento", + novo_valor="das 8 ate 18", + ) + + # WHEN / THEN + with pytest.raises(InvalidFieldValueError): + AutoApplyService().apply(payload, db) + + +def test_given_invalid_horario_time_when_applied_then_raises_422() -> None: + # GIVEN + enterprise = _make_enterprise() + db = _mock_db_with(enterprise) + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.ENTERPRISE, + campo_para_alterar="horario_funcionamento", + novo_valor="25:00-99:00", + ) + + # WHEN / THEN + with pytest.raises(InvalidFieldValueError): + AutoApplyService().apply(payload, db) + + +# --------------------------------------------------------------------------- +# Persistência +# --------------------------------------------------------------------------- + + +def test_given_db_failure_when_applied_then_raises_persistence_error() -> None: + # GIVEN + enterprise = _make_enterprise() + db = _mock_db_with(enterprise) + db.commit.side_effect = RuntimeError("connection lost") + payload = AutoApplyRequest( + enterprise_id=FAKE_ENTERPRISE_ID, + target=AutoApplyTarget.ENTERPRISE, + campo_para_alterar="historia", + novo_valor="x", + ) + + # WHEN / THEN + with pytest.raises(AutoApplyPersistenceError): + AutoApplyService().apply(payload, db) + db.rollback.assert_called_once() diff --git a/tests/test_report_auto_apply_service.py b/tests/test_report_auto_apply_service.py new file mode 100644 index 0000000..a9f7211 --- /dev/null +++ b/tests/test_report_auto_apply_service.py @@ -0,0 +1,355 @@ +""" +Testes unitários para ReportAutoApplyService. + +Foco: extração estruturada de sugestões via LLM e aplicação em lote +delegando ao AutoApplyService. Gemini e AutoApplyService são mockados. +""" + +import json +import uuid +from decimal import Decimal +from unittest.mock import MagicMock + +import pytest + +from app.core.exceptions import ( + AIReportNotFoundError, + EnterpriseNotFoundError, + FieldNotAllowedError, + SuggestionExtractionError, +) +from app.models.enterprise import Enterprise +from app.models.menu import CategoriaCardapio, Menu +from app.models.report import AIReport +from app.schemas.auto_apply import AutoApplyTarget, SuggestionStatus +from app.services.report_auto_apply_service import ReportAutoApplyService + +FAKE_REPORT_ID = uuid.uuid4() +FAKE_ENTERPRISE_ID = uuid.uuid4() +FAKE_MENU_ID = uuid.uuid4() + + +def _make_report() -> AIReport: + return AIReport( + id_relatorio=FAKE_REPORT_ID, + empresa_id=FAKE_ENTERPRISE_ID, + contexto_id=uuid.uuid4(), + pontos_positivos_resumo="ok", + pontos_positivos_detalhado="ok", + melhorias_resumo="aumentar telefone", + melhorias_detalhado="incluir telefone novo", + recomendacoes_resumo="ok", + recomendacoes_detalhado="ok", + ) + + +def _make_menu() -> Menu: + return Menu( + id_cardapio=FAKE_MENU_ID, + descricao="Frango grelhado", + historia=None, + preco=Decimal("20.00"), + categoria=CategoriaCardapio.PRATO_PRINCIPAL, + status=True, + empresa_id=FAKE_ENTERPRISE_ID, + ) + + +def _make_enterprise() -> Enterprise: + return Enterprise( + id_empresa=FAKE_ENTERPRISE_ID, + nome="Empresa Teste", + usuario_id=uuid.uuid4(), + ) + + +def _mock_db(report=None, enterprise=None, menus=None) -> MagicMock: + db = MagicMock() + + def _get(model, key): + if model is AIReport: + return report + if model is Enterprise: + return enterprise + return None + + db.get.side_effect = _get + execute_result = MagicMock() + execute_result.scalars.return_value.all.return_value = menus or [] + db.execute.return_value = execute_result + return db + + +def _mock_gemini(sugestoes: list[dict]) -> MagicMock: + client = MagicMock() + response = MagicMock() + response.text = json.dumps({"sugestoes": sugestoes}) + client.models.generate_content.return_value = response + return client + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_given_report_with_valid_suggestions_when_applied_then_all_succeed() -> None: + # GIVEN + db = _mock_db(report=_make_report(), enterprise=_make_enterprise(), menus=[_make_menu()]) + gemini = _mock_gemini( + [ + { + "target": "enterprise", + "menu_item_id": None, + "campo_para_alterar": "telefone", + "novo_valor": "81999990000", + }, + { + "target": "menu_item", + "menu_item_id": str(FAKE_MENU_ID), + "campo_para_alterar": "preco", + "novo_valor": "25.00", + }, + ] + ) + auto_apply = MagicMock() + service = ReportAutoApplyService(gemini_client=gemini, auto_apply_service=auto_apply) + + # WHEN + response = service.apply_from_report(FAKE_REPORT_ID, db) + + # THEN + assert response.total == 2 + assert response.aplicadas == 2 + assert response.rejeitadas == 0 + assert auto_apply.apply.call_count == 2 + assert all(r.status == SuggestionStatus.APPLIED for r in response.resultados) + db.commit.assert_called_once() + for call in auto_apply.apply.call_args_list: + assert call.kwargs.get("commit") is False + + +def test_given_llm_returns_empty_list_when_applied_then_returns_zero_counters() -> None: + # GIVEN + db = _mock_db(report=_make_report(), enterprise=_make_enterprise(), menus=[]) + gemini = _mock_gemini([]) + service = ReportAutoApplyService(gemini_client=gemini, auto_apply_service=MagicMock()) + + # WHEN + response = service.apply_from_report(FAKE_REPORT_ID, db) + + # THEN + assert response.total == 0 + assert response.aplicadas == 0 + assert response.rejeitadas == 0 + assert response.resultados == [] + db.commit.assert_not_called() + + +# --------------------------------------------------------------------------- +# Falhas individuais não derrubam o lote +# --------------------------------------------------------------------------- + + +def test_given_invalid_field_when_applied_then_marks_rejected() -> None: + # GIVEN + db = _mock_db(report=_make_report(), enterprise=_make_enterprise(), menus=[]) + gemini = _mock_gemini( + [ + { + "target": "enterprise", + "menu_item_id": None, + "campo_para_alterar": "owner_id", + "novo_valor": "x", + }, + ] + ) + auto_apply = MagicMock() + auto_apply.apply.side_effect = FieldNotAllowedError("owner_id") + service = ReportAutoApplyService(gemini_client=gemini, auto_apply_service=auto_apply) + + # WHEN + response = service.apply_from_report(FAKE_REPORT_ID, db) + + # THEN + assert response.total == 1 + assert response.aplicadas == 0 + assert response.rejeitadas == 1 + assert response.resultados[0].status == SuggestionStatus.REJECTED + assert "owner_id" in response.resultados[0].erro + + +def test_given_unknown_menu_id_when_applied_then_marks_rejected() -> None: + # GIVEN + db = _mock_db(report=_make_report(), enterprise=_make_enterprise(), menus=[_make_menu()]) + gemini = _mock_gemini( + [ + { + "target": "menu_item", + "menu_item_id": str(uuid.uuid4()), + "campo_para_alterar": "preco", + "novo_valor": "10.00", + }, + ] + ) + auto_apply = MagicMock() + auto_apply.apply.side_effect = EnterpriseNotFoundError(FAKE_ENTERPRISE_ID) + service = ReportAutoApplyService(gemini_client=gemini, auto_apply_service=auto_apply) + + # WHEN + response = service.apply_from_report(FAKE_REPORT_ID, db) + + # THEN + assert response.rejeitadas == 1 + assert response.resultados[0].status == SuggestionStatus.REJECTED + + +def test_given_mixed_suggestions_when_applied_then_partial_results_returned() -> None: + # GIVEN + db = _mock_db(report=_make_report(), enterprise=_make_enterprise(), menus=[_make_menu()]) + gemini = _mock_gemini( + [ + { + "target": "enterprise", + "menu_item_id": None, + "campo_para_alterar": "historia", + "novo_valor": "Nova", + }, + { + "target": "enterprise", + "menu_item_id": None, + "campo_para_alterar": "owner_id", + "novo_valor": "x", + }, + ] + ) + auto_apply = MagicMock() + auto_apply.apply.side_effect = [None, FieldNotAllowedError("owner_id")] + service = ReportAutoApplyService(gemini_client=gemini, auto_apply_service=auto_apply) + + # WHEN + response = service.apply_from_report(FAKE_REPORT_ID, db) + + # THEN + assert response.total == 2 + assert response.aplicadas == 1 + assert response.rejeitadas == 1 + + +# --------------------------------------------------------------------------- +# Erros bloqueantes +# --------------------------------------------------------------------------- + + +def test_given_missing_report_when_applied_then_raises_404() -> None: + # GIVEN + db = _mock_db(report=None) + service = ReportAutoApplyService( + gemini_client=MagicMock(), + auto_apply_service=MagicMock(), + ) + + # WHEN / THEN + with pytest.raises(AIReportNotFoundError): + service.apply_from_report(FAKE_REPORT_ID, db) + + +def test_given_llm_failure_when_applied_then_raises_extraction_error() -> None: + # GIVEN + db = _mock_db(report=_make_report(), enterprise=_make_enterprise(), menus=[]) + gemini = MagicMock() + gemini.models.generate_content.side_effect = RuntimeError("api down") + service = ReportAutoApplyService(gemini_client=gemini, auto_apply_service=MagicMock()) + + # WHEN / THEN + with pytest.raises(SuggestionExtractionError): + service.apply_from_report(FAKE_REPORT_ID, db) + + +def test_given_llm_returns_invalid_json_when_applied_then_raises_extraction_error() -> None: + # GIVEN + db = _mock_db(report=_make_report(), enterprise=_make_enterprise(), menus=[]) + gemini = MagicMock() + response = MagicMock() + response.text = "not a json" + gemini.models.generate_content.return_value = response + service = ReportAutoApplyService(gemini_client=gemini, auto_apply_service=MagicMock()) + + # WHEN / THEN + with pytest.raises(SuggestionExtractionError): + service.apply_from_report(FAKE_REPORT_ID, db) + + +# --------------------------------------------------------------------------- +# Construção do payload AutoApplyRequest +# --------------------------------------------------------------------------- + + +def test_given_suggestion_when_applied_then_request_uses_report_empresa_id() -> None: + # GIVEN + report = _make_report() + db = _mock_db(report=report, enterprise=_make_enterprise(), menus=[]) + gemini = _mock_gemini( + [ + { + "target": "enterprise", + "menu_item_id": None, + "campo_para_alterar": "telefone", + "novo_valor": "81000", + }, + ] + ) + auto_apply = MagicMock() + service = ReportAutoApplyService(gemini_client=gemini, auto_apply_service=auto_apply) + + # WHEN + service.apply_from_report(FAKE_REPORT_ID, db) + + # THEN + request_arg = auto_apply.apply.call_args[0][0] + assert request_arg.enterprise_id == FAKE_ENTERPRISE_ID + assert request_arg.target == AutoApplyTarget.ENTERPRISE + assert request_arg.campo_para_alterar == "telefone" + + +# --------------------------------------------------------------------------- +# Novos casos de segurança +# --------------------------------------------------------------------------- + + +def test_given_missing_enterprise_when_applied_then_raises_404() -> None: + # GIVEN — report existe mas empresa foi deletada + db = _mock_db(report=_make_report(), enterprise=None, menus=[]) + service = ReportAutoApplyService( + gemini_client=MagicMock(), + auto_apply_service=MagicMock(), + ) + + # WHEN / THEN + with pytest.raises(EnterpriseNotFoundError): + service.apply_from_report(FAKE_REPORT_ID, db) + + +def test_given_llm_returns_menu_without_id_when_applied_then_marks_rejected() -> None: + # GIVEN — LLM retorna menu_item sem menu_item_id (violação de schema) + db = _mock_db(report=_make_report(), enterprise=_make_enterprise(), menus=[]) + gemini = _mock_gemini( + [ + { + "target": "menu_item", + "menu_item_id": None, + "campo_para_alterar": "preco", + "novo_valor": "15.00", + }, + ] + ) + service = ReportAutoApplyService(gemini_client=gemini, auto_apply_service=MagicMock()) + + # WHEN + response = service.apply_from_report(FAKE_REPORT_ID, db) + + # THEN — ValueError do model_validator capturado como rejeição, não crash + assert response.total == 1 + assert response.rejeitadas == 1 + assert response.resultados[0].status == SuggestionStatus.REJECTED + assert response.resultados[0].erro is not None diff --git a/tests/test_reports_router.py b/tests/test_reports_router.py index 7a54141..c018842 100644 --- a/tests/test_reports_router.py +++ b/tests/test_reports_router.py @@ -17,10 +17,18 @@ AIReportGenerationError, AIReportNotFoundError, BusinessContextNotFoundError, + SuggestionExtractionError, ) from app.main import app from app.models.report import AIReport -from app.routers.reports import get_report_service +from app.routers.reports import get_report_auto_apply_service, get_report_service +from app.schemas.auto_apply import ( + AutoApplySuggestion, + AutoApplySuggestionResult, + AutoApplyTarget, + ReportAutoApplyResponse, + SuggestionStatus, +) FAKE_REPORT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001") FAKE_EMPRESA_ID = uuid.UUID("00000000-0000-0000-0000-000000000002") @@ -241,3 +249,79 @@ def test_given_invalid_uuid_when_get_by_id_then_returns_422(): # THEN assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# POST /reports/{report_id}/auto-apply +# --------------------------------------------------------------------------- + + +def _make_auto_apply_client(mock_service: MagicMock) -> TestClient: + app.dependency_overrides[get_report_auto_apply_service] = lambda: mock_service + return TestClient(app, raise_server_exceptions=False) + + +def test_given_valid_report_when_auto_apply_then_returns_200(): + # GIVEN + mock_service = MagicMock() + mock_service.apply_from_report.return_value = ReportAutoApplyResponse( + report_id=FAKE_REPORT_ID, + total=1, + aplicadas=1, + rejeitadas=0, + resultados=[ + AutoApplySuggestionResult( + sugestao=AutoApplySuggestion( + target=AutoApplyTarget.ENTERPRISE, + campo_para_alterar="telefone", + novo_valor="81999990000", + ), + status=SuggestionStatus.APPLIED, + ) + ], + ) + client = _make_auto_apply_client(mock_service) + + # WHEN + response = client.post(f"/reports/{FAKE_REPORT_ID}/auto-apply") + + # THEN + assert response.status_code == 200 + body = response.json() + assert body["report_id"] == str(FAKE_REPORT_ID) + assert body["aplicadas"] == 1 + assert body["rejeitadas"] == 0 + + +def test_given_missing_report_when_auto_apply_then_returns_404(): + # GIVEN + mock_service = MagicMock() + mock_service.apply_from_report.side_effect = AIReportNotFoundError(FAKE_REPORT_ID) + client = _make_auto_apply_client(mock_service) + + # WHEN + response = client.post(f"/reports/{FAKE_REPORT_ID}/auto-apply") + + # THEN + assert response.status_code == 404 + + +def test_given_llm_failure_when_auto_apply_then_returns_502(): + # GIVEN + mock_service = MagicMock() + mock_service.apply_from_report.side_effect = SuggestionExtractionError("api down") + client = _make_auto_apply_client(mock_service) + + # WHEN + response = client.post(f"/reports/{FAKE_REPORT_ID}/auto-apply") + + # THEN + assert response.status_code == 502 + + +def test_given_invalid_uuid_when_auto_apply_then_returns_422(): + # WHEN + response = TestClient(app, raise_server_exceptions=False).post("/reports/not-a-uuid/auto-apply") + + # THEN + assert response.status_code == 422