Pablo 4 napja
commit
ad8eeb9c9d

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+.env.*
+__pycache__
+.pytest
+.coverage

+ 0 - 0
app/__init__.py


+ 23 - 0
app/config.py

@@ -0,0 +1,23 @@
+import os
+from pathlib import Path
+from dotenv import load_dotenv
+
+BASE_DIR = Path(__file__).resolve().parent
+ENVIRONMENT = os.getenv("ENVIRONMENT", "develop")
+ENV_FILE = BASE_DIR / f".env.{ENVIRONMENT}"
+
+if ENV_FILE.exists():
+    load_dotenv(dotenv_path=ENV_FILE)
+else:
+    load_dotenv()
+
+
+class Config:
+    PORT = int(os.getenv("PORT", "8000"))
+    LOG_LEVEL = os.getenv("LOG_LEVEL", "info")
+    SCREENSHOT_QUALITY = int(os.getenv("SCREENSHOT_QUALITY", "60"))
+    HEADLESS_MODE = os.getenv("HEADLESS_MODE", "auto")
+    ENVIRONMENT = os.getenv("ENVIRONMENT", "develop")
+
+
+config = Config()

+ 0 - 0
app/controllers/__init__.py


+ 35 - 0
app/controllers/api_controller.py

@@ -0,0 +1,35 @@
+import time
+from typing import Any
+from fastapi import Request
+from app.models.schemas import VisitRequest
+from app.use_cases.camoufox.visit import VisitUseCase
+from app.libs.log import log
+from app.libs.aras_response import aras_ok, aras_server_error
+
+
+class ApiController:
+    def __init__(self) -> None:
+        self.visit_use_case = VisitUseCase()
+
+    def ping(self) -> dict[str, Any]:
+        log.logger.info("Ping received")
+        return aras_ok("pong")
+
+    async def visit(self, request: Request, payload: VisitRequest) -> dict[str, Any]:
+        start_time = time.time()
+        try:
+            options = payload.model_dump(exclude={"url"})
+            options["headers"] = dict(request.scope.get("raw_headers_dict", {}))
+
+            log.logger.info(f"Visiting URL: {payload.url}")
+            log.logger.debug(f"Options: {options}")
+
+            response = await self.visit_use_case.execute(str(payload.url), options)
+
+            return aras_ok("OK", response)
+        except Exception as exc:
+            log.logger.error(exc)
+            return aras_server_error(str(exc))
+        finally:
+            duration = time.time() - start_time
+            log.logger.info(f"Execution time: {duration:.2f}s")

+ 0 - 0
app/libs/__init__.py


+ 31 - 0
app/libs/aras_response.py

@@ -0,0 +1,31 @@
+from typing import Any, Optional
+
+
+def aras_ok(
+    message: str,
+    data: Any = None,
+    extra: Any = None,
+    code: str = "UNKNOWN_CODE",
+) -> dict[str, Any]:
+    return {
+        "status": "OK",
+        "message": message,
+        "data": data,
+        "extra": extra,
+        "code": code,
+    }
+
+
+def aras_server_error(
+    error: str,
+    data: Any = None,
+    extra: Any = None,
+    code: str = "UNKNOWN_CODE",
+) -> dict[str, Any]:
+    return {
+        "status": "NO_OK",
+        "message": error,
+        "data": data,
+        "extra": extra,
+        "code": code,
+    }

+ 34 - 0
app/libs/log.py

@@ -0,0 +1,34 @@
+import logging
+import sys
+from app.config import config
+
+
+class Log:
+    _instance: "Log" = None
+
+    def __new__(cls) -> "Log":
+        if cls._instance is None:
+            cls._instance = super().__new__(cls)
+            cls._instance._configure()
+        return cls._instance
+
+    def _configure(self) -> None:
+        self._logger = logging.getLogger("camoufox_api")
+        self._logger.setLevel(getattr(logging, config.LOG_LEVEL.upper(), logging.INFO))
+
+        if not self._logger.handlers:
+            handler = logging.StreamHandler(sys.stdout)
+            handler.setLevel(self._logger.level)
+            formatter = logging.Formatter(
+                "%(asctime)s | %(levelname)s | %(message)s",
+                datefmt="%Y-%m-%d %H:%M:%S",
+            )
+            handler.setFormatter(formatter)
+            self._logger.addHandler(handler)
+
+    @property
+    def logger(self) -> logging.Logger:
+        return self._logger
+
+
+log = Log()

+ 16 - 0
app/main.py

@@ -0,0 +1,16 @@
+from fastapi import FastAPI
+from app.routers.v1 import api, web
+from app.middlewares.case_sensitive_headers import case_sensitive_headers_middleware
+
+app = FastAPI(
+    title="Camoufox RESTful API",
+    description="API con Camoufox para scraping de URLs",
+    version="1.0.0",
+    docs_url="/swagger",
+    redoc_url="/redoc",
+)
+
+app.middleware("http")(case_sensitive_headers_middleware)
+
+app.include_router(api.router, prefix="/api/v1")
+app.include_router(web.router)

+ 0 - 0
app/middlewares/__init__.py


+ 12 - 0
app/middlewares/case_sensitive_headers.py

@@ -0,0 +1,12 @@
+from fastapi import Request
+
+
+async def case_sensitive_headers_middleware(request: Request, call_next):
+    raw_headers = request.scope.get("raw_headers", [])
+    normalized = {}
+    for i in range(0, len(raw_headers), 2):
+        key = raw_headers[i].decode("utf-8")
+        value = raw_headers[i + 1].decode("utf-8")
+        normalized[key] = value
+    request.scope["raw_headers_dict"] = normalized
+    return await call_next(request)

+ 0 - 0
app/models/__init__.py


+ 22 - 0
app/models/schemas.py

@@ -0,0 +1,22 @@
+from typing import Any, Literal, Optional
+from pydantic import BaseModel, Field, HttpUrl
+
+
+WaitUntilValue = Literal["load", "domcontentloaded", "networkidle", "commit"]
+
+
+class VisitRequest(BaseModel):
+    url: HttpUrl
+    screenshot: bool = False
+    waitUntil: WaitUntilValue = Field(default="load")
+    proxy: Optional[str] = None
+    proxyAuth: Optional[dict[str, Any]] = None
+    incognito: bool = False
+
+
+class ArasResponse(BaseModel):
+    status: str
+    message: str
+    data: Any = None
+    extra: Any = None
+    code: str = "UNKNOWN_CODE"

+ 0 - 0
app/routers/__init__.py


+ 0 - 0
app/routers/v1/__init__.py


+ 16 - 0
app/routers/v1/api.py

@@ -0,0 +1,16 @@
+from fastapi import APIRouter, Request
+from app.controllers.api_controller import ApiController
+from app.models.schemas import VisitRequest, ArasResponse
+
+router = APIRouter()
+controller = ApiController()
+
+
+@router.get("/ping", response_model=ArasResponse)
+async def ping():
+    return controller.ping()
+
+
+@router.post("/visit", response_model=ArasResponse)
+async def visit(request: Request, payload: VisitRequest):
+    return await controller.visit(request, payload)

+ 8 - 0
app/routers/v1/web.py

@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+router = APIRouter()
+
+
+@router.get("/ping")
+async def web_ping():
+    return "pong"

+ 16 - 0
app/server.py

@@ -0,0 +1,16 @@
+import uvicorn
+from app.config import config
+
+
+def start() -> None:
+    uvicorn.run(
+        "app.main:app",
+        host="0.0.0.0",
+        port=config.PORT,
+        reload=config.ENVIRONMENT == "develop",
+        log_level=config.LOG_LEVEL.lower(),
+    )
+
+
+if __name__ == "__main__":
+    start()

+ 0 - 0
app/use_cases/__init__.py


+ 0 - 0
app/use_cases/camoufox/__init__.py


+ 9 - 0
app/use_cases/camoufox/visit.py

@@ -0,0 +1,9 @@
+from app.use_cases.camoufox.wrapper import CamoufoxWrapper
+
+
+class VisitUseCase:
+    def __init__(self) -> None:
+        self.wrapper = CamoufoxWrapper()
+
+    async def execute(self, url: str, options: dict) -> dict:
+        return await self.wrapper.visit(url, options)

+ 184 - 0
app/use_cases/camoufox/wrapper.py

@@ -0,0 +1,184 @@
+import asyncio
+import base64
+import shutil
+import sys
+import time
+from typing import Any, Literal, Optional, Union
+from urllib.parse import urlparse
+from camoufox.async_api import AsyncCamoufox
+from app.config import config
+from app.libs.log import log
+
+
+def _detect_headless_mode() -> Union[bool, Literal["virtual"]]:
+    if sys.platform.startswith("linux") and shutil.which("Xvfb"):
+        return "virtual"
+    return True
+
+
+def _resolve_headless_mode() -> Union[bool, Literal["virtual"]]:
+    env_mode = config.HEADLESS_MODE
+    if env_mode == "virtual":
+        return "virtual"
+    if env_mode in ("true", "True", "1"):
+        return True
+    if env_mode in ("false", "False", "0"):
+        return False
+    return _detect_headless_mode()
+
+
+class CamoufoxWrapper:
+    _instance: Optional["CamoufoxWrapper"] = None
+
+    def __new__(cls) -> "CamoufoxWrapper":
+        if cls._instance is None:
+            cls._instance = super().__new__(cls)
+        return cls._instance
+
+    def _build_proxy_config(
+        self, proxy: str, proxy_auth: Optional[dict[str, Any]] = None
+    ) -> dict[str, Any]:
+        proxy_url = urlparse(proxy if proxy.startswith("http") else f"http://{proxy}")
+        config_proxy: dict[str, Any] = {
+            "server": f"{proxy_url.scheme}://{proxy_url.hostname}:{proxy_url.port}",
+        }
+        if proxy_auth:
+            if proxy_auth.get("username"):
+                config_proxy["username"] = proxy_auth["username"]
+            if proxy_auth.get("password"):
+                config_proxy["password"] = proxy_auth["password"]
+        return config_proxy
+
+    async def _handle_mercadolibre(self, page, url: str, browser) -> str:
+        """Hacks específicos para el portal de Mercado Libre."""
+        html = await page.content()
+
+        # 1. Detectar captcha "Para continuar"
+        if "Para continuar" in html:
+            log.logger.debug("MeLi captcha detected. Reloading in new page...")
+            page = await browser.new_page()
+            page.set_default_navigation_timeout(60000)
+            await page.goto(url, timeout=60000)
+            html = await page.content()
+
+        # 2. Espera adicional de network idle
+        await page.wait_for_load_state("networkidle", timeout=2000)
+
+        # 3. Rate limit detection (429)
+        html = await page.content()
+        rate_limit_pattern = '{"message":"local_rate_limited","status":429}'
+        if rate_limit_pattern in html:
+            log.logger.debug("Rate limit detected for MeLi. Reloading page...")
+            await page.reload()
+            html = await page.content()
+
+        # 4. Remover banner de cookies
+        try:
+            await page.wait_for_selector("div.cookie-consent-banner-opt-out", timeout=1000)
+            await page.evaluate(
+                "() => { const banner = document.querySelector('div.cookie-consent-banner-opt-out'); if (banner) banner.remove(); }"
+            )
+        except Exception:
+            pass
+
+        # 5. Click "todas las características"
+        try:
+            await page.wait_for_selector(
+                "button.ui-pdp-collapsable__action.ui-vpp-highlighted-specs__striped-collapsed__action[data-testid='action-collapsable-target']",
+                timeout=1000,
+            )
+            await page.click(
+                "button.ui-pdp-collapsable__action.ui-vpp-highlighted-specs__striped-collapsed__action[data-testid='action-collapsable-target']"
+            )
+        except Exception:
+            pass
+
+        # 6. Click "toda la descripción"
+        try:
+            await page.wait_for_selector(
+                "button.ui-pdp-collapsable__action.ui-pdp-description-collapse[data-testid='action-collapsable-target']",
+                timeout=1000,
+            )
+            await page.click(
+                "button.ui-pdp-collapsable__action.ui-pdp-description-collapse[data-testid='action-collapsable-target']"
+            )
+        except Exception:
+            pass
+
+        # HTML final actualizado
+        html = await page.content()
+        return html
+
+    async def visit(self, url: str, options: dict[str, Any]) -> dict[str, Any]:
+        start_time = time.time()
+
+        headless = _resolve_headless_mode()
+        log.logger.info(f"Using headless mode: {headless}")
+
+        camoufox_options: dict[str, Any] = {
+            "headless": headless,
+        }
+
+        proxy = options.get("proxy")
+        if proxy:
+            log.logger.debug(f"Using proxy: {proxy}")
+            camoufox_options["proxy"] = self._build_proxy_config(
+                proxy, options.get("proxyAuth")
+            )
+            camoufox_options["geoip"] = True
+
+        headers = options.get("headers")
+        if headers:
+            if "host" in headers:
+                del headers["host"]
+            if "content-length" in headers:
+                del headers["content-length"]
+            log.logger.debug(f"Using headers: {headers}")
+
+        # Intentar inicializar AsyncCamoufox (con fallback si es virtual)
+        try:
+            browser_cls = AsyncCamoufox(**camoufox_options)
+        except Exception as launch_exc:
+            if headless == "virtual":
+                log.logger.warning(
+                    f"Failed to launch with virtual display: {launch_exc}. Falling back to headless=True"
+                )
+                camoufox_options["headless"] = True
+                browser_cls = AsyncCamoufox(**camoufox_options)
+            else:
+                raise
+
+        # Usar context manager para toda la lógica del browser
+        async with browser_cls as browser:
+            page = await browser.new_page()
+
+            if headers:
+                await page.set_extra_http_headers(headers)
+
+            await page.goto(url, timeout=120000, wait_until=options.get("waitUntil", "load"))
+
+            await page.wait_for_load_state("networkidle", timeout=120000)
+
+            # Hacks específicos para Mercado Libre
+            is_meli = "mercadolibre.cl" in url
+            if is_meli:
+                log.logger.debug("Applying MeLi-specific hacks")
+                html = await self._handle_mercadolibre(page, url, browser)
+            else:
+                html = await page.content()
+
+            screenshot: Optional[str] = None
+            if options.get("screenshot"):
+                screenshot_bytes = await page.screenshot(
+                    full_page=True,
+                    type="jpeg",
+                    quality=config.SCREENSHOT_QUALITY,
+                )
+                screenshot = base64.b64encode(screenshot_bytes).decode("utf-8")
+
+            duration = time.time() - start_time
+            log.logger.info(f"HTML Size: {len(html) / 1000:.2f} KB from '{url}'")
+            log.logger.info(f"Execution time: {duration:.2f}s")
+
+            return {"html": html, "screenshot": screenshot}
+

+ 11 - 0
bitbucket-pipelines.yml

@@ -0,0 +1,11 @@
+image:
+  name: 779018688835.dkr.ecr.us-east-1.amazonaws.com/builder-python312:prod
+  aws:
+    access-key: $AWS_BITBUCKET_ACCESS_KEY
+    secret-key: $AWS_BITBUCKET_SECRET_KEY
+
+pipelines:
+  default:
+    - step:
+        script:
+          - /build

+ 20 - 0
definition.md

@@ -0,0 +1,20 @@
+Definición de proyecto
+=========================
+
+Nombre: operacion-api-camoufox
+
+Family: python312-web
+
+Tarea Origen: 
+
+* ¿Quién lo usa?: Scrapy de Precio
+
+
+* ¿Cómo se usa?: API Rest
+
+
+* ¿Qué usa?: Camoufox, Proxys
+
+
+* ¿Qué hace?: Scraping de sitios web
+

+ 6 - 0
example.env

@@ -0,0 +1,6 @@
+PORT=8000
+LOG_LEVEL=info
+SCREENSHOT_QUALITY=60
+HEADLESS_MODE=auto
+ENVIRONMENT=develop
+ROTATING_PROXY_LIST=

+ 4 - 0
main.py

@@ -0,0 +1,4 @@
+from app.server import start
+
+if __name__ == "__main__":
+    start()

+ 15 - 0
package.json

@@ -0,0 +1,15 @@
+{
+  "autofact": {
+    "family": "python312-web",
+    "webRoot": "/var/www/app",
+    "requires": [
+      "vpc"
+    ]
+  },
+  "name": "@autofact/operacion-api-camoufox",
+  "version": "1.0.0",
+  "scripts": {
+    "coverage": "python -m pytest tests/ --cov=app --cov=settings --cov-report=xml --cov-report=term"
+  },
+  "main": "main:app --workers 1 --timeout 30 -k uvicorn.workers.UvicornWorker"
+}

+ 4 - 0
pytest.ini

@@ -0,0 +1,4 @@
+[pytest]
+markers =
+    integration: tests que requieren Camoufox funcionando (saltar en CI con -m 'not integration')
+addopts = -m 'not integration' --cov=app --cov-report=term-missing --cov-fail-under=74

+ 73 - 0
requirements.txt

@@ -0,0 +1,73 @@
+aiohappyeyeballs==2.7.1
+aiohttp==3.14.3
+aiosignal==1.4.0
+annotated-doc==0.0.5
+annotated-types==0.8.0
+anyio==4.14.2
+apify_fingerprint_datapoints==0.15.0
+attrs==26.1.0
+blessed==1.48.0
+browserforge==1.2.4
+camoufox==0.5.4
+certifi==2026.7.22
+charset-normalizer==3.4.9
+click==8.4.2
+coverage==7.15.4
+Cython==3.2.9
+editor==1.8.0
+fastapi==0.141.1
+frozenlist==1.8.0
+geoip2==5.3.0
+greenlet==3.5.5
+h11==0.16.0
+httpcore==1.0.9
+httpx==0.28.1
+idna==3.18
+iniconfig==2.3.0
+inquirer==3.4.1
+jinxed==2.1.0
+language-tags==1.3.1
+lxml==6.1.1
+markdown-it-py==4.2.0
+maxminddb==3.1.1
+mdurl==0.1.2
+multidict==6.7.1
+numpy==2.5.2
+orjson==3.11.9
+packaging==26.3
+platformdirs==4.11.2
+playwright==1.60.0
+pluggy==1.6.0
+propcache==0.5.2
+pydantic==2.13.4
+pydantic_core==2.46.4
+pyee==13.0.1
+Pygments==2.20.0
+pyobjc-core==12.2.1; sys_platform == 'darwin'
+pyobjc-framework-Cocoa==12.2.1; sys_platform == 'darwin'
+PySide6==6.11.1
+PySide6_Addons==6.11.1
+PySide6_Essentials==6.11.1
+PySocks==1.7.1
+pytest==9.1.1
+pytest-asyncio==1.4.0
+pytest-cov==7.1.0
+python-dotenv==1.2.2
+PyYAML==6.0.3
+readchar==4.2.2
+requests==2.34.2
+rich==15.0.0
+rich-click==1.9.8
+runs==1.3.0
+screeninfo==0.8.1
+shiboken6==6.11.1
+starlette==1.6.0
+typing-inspection==0.4.3
+typing_extensions==4.16.0
+ua-parser==1.0.2
+ua-parser-builtins==202606
+urllib3==2.7.0
+uvicorn==0.52.1
+wcwidth==0.8.2
+xmod==1.10.0
+yarl==1.24.5

+ 0 - 0
tests/__init__.py


+ 163 - 0
tests/test_api_controller.py

@@ -0,0 +1,163 @@
+"""
+Tests unitarios del endpoint /visit con mocks para no depender de Camoufox.
+Importamos app.main dentro del contexto del patch para asegurar que todos
+los módulos se carguen con AsyncCamoufox mockeado.
+"""
+from contextlib import contextmanager
+from unittest.mock import AsyncMock, patch
+
+
+@contextmanager
+def mocked_client(mock_response=None):
+    """Context manager que crea un TestClient con AsyncCamoufox mockeado.
+    Importa app.main dentro del contexto del patch."""
+    if mock_response is None:
+        mock_response = {"html": "<html>Example</html>", "screenshot": None}
+
+    with patch("app.use_cases.camoufox.wrapper.AsyncCamoufox") as MockClass:
+        mock_browser = AsyncMock()
+        mock_page = AsyncMock()
+        mock_page.content = AsyncMock(return_value=mock_response["html"])
+        mock_page.screenshot = AsyncMock(
+            return_value=b"fake_screenshot_bytes" if mock_response.get("screenshot") else None
+        )
+        mock_page.set_extra_http_headers = AsyncMock()
+        mock_page.goto = AsyncMock()
+        mock_page.wait_for_load_state = AsyncMock()
+        mock_page.set_default_navigation_timeout = AsyncMock()
+        mock_browser.new_page = AsyncMock(return_value=mock_page)
+
+        MockClass.return_value.__aenter__ = AsyncMock(return_value=mock_browser)
+        MockClass.return_value.__aexit__ = AsyncMock(return_value=False)
+
+        # Importar app DENTRO del patch para que se cargue con AsyncCamoufox mockeado
+        from app.main import app
+        from fastapi.testclient import TestClient
+
+        yield TestClient(app)
+
+
+def test_visit_missing_url():
+    from app.main import app
+    from fastapi.testclient import TestClient
+
+    client = TestClient(app)
+    response = client.post("/api/v1/visit", json={})
+    assert response.status_code == 422
+
+
+class TestVisitEndpoint:
+    """Tests que mockean AsyncCamoufox para evitar llamadas reales al browser."""
+
+    def test_visit_invalid_url(self):
+        with mocked_client() as client:
+            response = client.post("/api/v1/visit", json={"url": "not-a-url"})
+            assert response.status_code == 422
+
+    def test_visit_valid_url(self):
+        with mocked_client() as client:
+            response = client.post(
+                "/api/v1/visit",
+                json={"url": "https://example.com", "screenshot": False},
+            )
+            assert response.status_code == 200
+            body = response.json()
+            assert body["status"] == "OK"
+            assert "html" in body["data"]
+
+    def test_visit_with_screenshot(self):
+        with mocked_client(
+            {"html": "<html>Example</html>", "screenshot": "base64data"}
+        ) as client:
+            response = client.post(
+                "/api/v1/visit",
+                json={"url": "https://example.com", "screenshot": True},
+            )
+            assert response.status_code == 200
+            body = response.json()
+            assert body["status"] == "OK"
+            assert "screenshot" in body["data"]
+
+    def test_visit_with_invalid_waituntil(self):
+        with mocked_client() as client:
+            response = client.post(
+                "/api/v1/visit",
+                json={"url": "https://example.com", "waitUntil": "invalid"},
+            )
+            assert response.status_code == 422
+
+    def test_visit_with_valid_waituntil(self):
+        with mocked_client() as client:
+            for wait_until in ["load", "domcontentloaded", "networkidle", "commit"]:
+                response = client.post(
+                    "/api/v1/visit",
+                    json={"url": "https://example.com", "waitUntil": wait_until},
+                )
+                assert response.status_code == 200
+
+    def test_visit_response_format(self):
+        with mocked_client() as client:
+            response = client.post(
+                "/api/v1/visit",
+                json={"url": "https://example.com"},
+            )
+            body = response.json()
+            assert "status" in body
+            assert "message" in body
+            assert "data" in body
+            assert "code" in body
+            assert body["code"] == "UNKNOWN_CODE"
+
+
+class TestVisitEndpointController:
+    """Tests que verifican el controller directamente con VisitUseCase mockeado."""
+
+    def test_controller_visit_uses_visit_use_case(self):
+        """Test que cubre la llamada a use_case.execute."""
+        from app.controllers.api_controller import ApiController
+        from app.models.schemas import VisitRequest
+        from fastapi import Request
+        from unittest.mock import MagicMock
+
+        controller = ApiController()
+        mock_request = MagicMock(spec=Request)
+        mock_request.scope = {"raw_headers_dict": {"user-agent": "test"}}
+
+        with patch.object(controller, "visit_use_case") as mock_use_case:
+            async def async_execute(*args, **kwargs):
+                return {"html": "<html>Controller</html>", "screenshot": None}
+
+            mock_use_case.execute = async_execute
+
+            import asyncio
+            result = asyncio.run(
+                controller.visit(mock_request, VisitRequest(url="https://example.com"))
+            )
+
+            assert result["status"] == "OK"
+            assert result["data"]["html"] == "<html>Controller</html>"
+
+    def test_controller_visit_error_handling(self):
+        """Test que cubre el manejo de errores."""
+        from app.controllers.api_controller import ApiController
+        from app.models.schemas import VisitRequest
+        from fastapi import Request
+        from unittest.mock import MagicMock
+
+        controller = ApiController()
+        mock_request = MagicMock(spec=Request)
+        mock_request.scope = {"raw_headers_dict": {"user-agent": "test"}}
+
+        with patch.object(controller, "visit_use_case") as mock_use_case:
+            async def async_execute(*args, **kwargs):
+                raise Exception("Test error")
+
+            mock_use_case.execute = async_execute
+
+            import asyncio
+            result = asyncio.run(
+                controller.visit(mock_request, VisitRequest(url="https://example.com"))
+            )
+
+            assert result["status"] == "NO_OK"
+            assert "Test error" in result["message"]

+ 57 - 0
tests/test_aras_response.py

@@ -0,0 +1,57 @@
+from app.libs.aras_response import aras_ok, aras_server_error
+
+
+def test_aras_ok_default():
+    result = aras_ok("test message")
+    assert result["status"] == "OK"
+    assert result["message"] == "test message"
+    assert result["data"] is None
+    assert result["extra"] is None
+    assert result["code"] == "UNKNOWN_CODE"
+
+
+def test_aras_ok_with_data():
+    result = aras_ok("test", data={"key": "value"})
+    assert result["data"] == {"key": "value"}
+
+
+def test_aras_ok_with_all_params():
+    result = aras_ok(
+        "test",
+        data={"key": "value"},
+        extra={"extra_key": "extra_value"},
+        code="CUSTOM_CODE",
+    )
+    assert result["status"] == "OK"
+    assert result["message"] == "test"
+    assert result["data"] == {"key": "value"}
+    assert result["extra"] == {"extra_key": "extra_value"}
+    assert result["code"] == "CUSTOM_CODE"
+
+
+def test_aras_server_error_default():
+    result = aras_server_error("error message")
+    assert result["status"] == "NO_OK"
+    assert result["message"] == "error message"
+    assert result["data"] is None
+    assert result["extra"] is None
+    assert result["code"] == "UNKNOWN_CODE"
+
+
+def test_aras_server_error_with_data():
+    result = aras_server_error("error", data={"error_key": "error_value"})
+    assert result["data"] == {"error_key": "error_value"}
+
+
+def test_aras_server_error_with_all_params():
+    result = aras_server_error(
+        "error",
+        data={"error_key": "error_value"},
+        extra={"extra_key": "extra_value"},
+        code="ERROR_CODE",
+    )
+    assert result["status"] == "NO_OK"
+    assert result["message"] == "error"
+    assert result["data"] == {"error_key": "error_value"}
+    assert result["extra"] == {"extra_key": "extra_value"}
+    assert result["code"] == "ERROR_CODE"

+ 98 - 0
tests/test_camoufox.py

@@ -0,0 +1,98 @@
+import argparse
+import os
+import random
+from pathlib import Path
+from urllib.parse import urlparse
+from dotenv import load_dotenv
+from camoufox.sync_api import Camoufox
+
+BASE_DIR = Path(__file__).resolve().parent
+ENVIRONMENT = os.getenv("ENVIRONMENT", "develop")
+ENV_FILE = BASE_DIR / f".env.{ENVIRONMENT}"
+
+if ENV_FILE.exists():
+    load_dotenv(dotenv_path=ENV_FILE)
+else:
+    load_dotenv()
+
+
+def _load_rotating_proxy_list() -> list[str]:
+    raw = os.getenv("ROTATING_PROXY_LIST", "")
+    if not raw:
+        return []
+    return [proxy.strip() for proxy in raw.split(",") if proxy.strip()]
+
+
+ROTATING_PROXY_LIST = _load_rotating_proxy_list()
+DEFAULT_URL = "https://example.com"
+
+
+def make_proxy_config(proxy: str) -> dict:
+    proxy_url = urlparse(f"http://{proxy}")
+    return {
+        "server": f"http://{proxy_url.hostname}:{proxy_url.port}",
+    }
+
+
+def get_screenshot_filename(url: str) -> str:
+    domain = urlparse(url).netloc
+    if domain.startswith("www."):
+        domain = domain[4:]
+    if not domain:
+        domain = "screenshot"
+    return f"{domain}.png"
+
+
+def try_visit(url: str, proxy: str | None = None) -> None:
+    kwargs = {
+        "headless": True,
+    }
+    if proxy:
+        print(f"Usando proxy: {proxy}")
+        kwargs["proxy"] = make_proxy_config(proxy)
+        kwargs["geoip"] = True
+    else:
+        print("No se usará proxy")
+
+    screenshot_path = get_screenshot_filename(url)
+
+    with Camoufox(**kwargs) as browser:
+        page = browser.new_page()
+        page.goto(url, timeout=120000)
+        page.wait_for_load_state("networkidle", timeout=120000)
+        page.screenshot(path=screenshot_path)
+        print(f"Screenshot guardado como {screenshot_path}")
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Visita una URL con Camoufox")
+    parser.add_argument(
+        "url",
+        nargs="?",
+        default=DEFAULT_URL,
+        help="URL a visitar (por defecto: página de Mercado Libre)",
+    )
+    parser.add_argument(
+        "--use-proxy",
+        action="store_true",
+        help="Usar un proxy aleatorio de ROTATING_PROXY_LIST",
+    )
+    args = parser.parse_args()
+
+    if args.use_proxy:
+        max_attempts = 5
+        for attempt in range(1, max_attempts + 1):
+            proxy = random.choice(ROTATING_PROXY_LIST)
+            try:
+                try_visit(args.url, proxy=proxy)
+                break
+            except Exception as exc:
+                print(f"Intento {attempt} falló: {exc}")
+                if attempt == max_attempts:
+                    raise
+    else:
+        try_visit(args.url)
+
+
+if __name__ == "__main__":
+    main()

+ 21 - 0
tests/test_config.py

@@ -0,0 +1,21 @@
+from app.config import Config, config
+
+
+def test_config_default_port():
+    assert config.PORT == 8000
+
+
+def test_config_default_log_level():
+    assert config.LOG_LEVEL == "info"
+
+
+def test_config_default_screenshot_quality():
+    assert config.SCREENSHOT_QUALITY == 60
+
+
+def test_config_default_environment():
+    assert config.ENVIRONMENT == "develop"
+
+
+def test_config_default_headless_mode():
+    assert config.HEADLESS_MODE == "auto"

+ 73 - 0
tests/test_integration.py

@@ -0,0 +1,73 @@
+"""
+Tests de integración que requieren Camoufox funcionando.
+Estos tests pueden saltarse en CI con: pytest -m "not integration"
+"""
+import pytest
+from fastapi.testclient import TestClient
+from app.main import app
+
+client = TestClient(app)
+
+
+@pytest.mark.integration
+class TestVisitEndpointIntegration:
+    """Tests que ejecutan Camoufox realmente."""
+
+    def test_visit_example_com(self):
+        """Test básico con example.com."""
+        response = client.post(
+            "/api/v1/visit",
+            json={"url": "https://example.com", "screenshot": False},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        assert body["status"] == "OK"
+        assert "html" in body["data"]
+
+    @pytest.mark.skip(reason="Requiere Camoufox instalado y funcionando")
+    def test_visit_with_screenshot(self):
+        """Test con screenshot."""
+        response = client.post(
+            "/api/v1/visit",
+            json={"url": "https://example.com", "screenshot": True},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        assert body["status"] == "OK"
+        assert body["data"]["screenshot"] is not None
+
+    @pytest.mark.skip(reason="Requiere Camoufox instalado y funcionando")
+    def test_visit_mercadolibre_url(self):
+        """Test con URL de Mercado Libre (aplica hacks específicos)."""
+        response = client.post(
+            "/api/v1/visit",
+            json={
+                "url": "https://autos.mercadolibre.cl/auto",
+                "screenshot": False,
+            },
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.skip(reason="Requiere Camoufox instalado y funcionando")
+    def test_visit_with_proxy(self):
+        """Test con proxy."""
+        response = client.post(
+            "/api/v1/visit",
+            json={
+                "url": "https://example.com",
+                "proxy": "http://proxy:8080",
+            },
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.skip(reason="Requiere Camoufox instalado y funcionando")
+    def test_visit_with_incognito(self):
+        """Test con modo incógnito."""
+        response = client.post(
+            "/api/v1/visit",
+            json={
+                "url": "https://example.com",
+                "incognito": True,
+            },
+        )
+        assert response.status_code == 200

+ 9 - 0
tests/test_main.py

@@ -0,0 +1,9 @@
+"""Test trivial para main.py"""
+from unittest.mock import patch
+import runpy
+
+
+def test_main_runs_start():
+    with patch("app.server.start") as mock_start:
+        runpy.run_module("main", run_name="__main__")
+        mock_start.assert_called_once()

+ 34 - 0
tests/test_middlewares.py

@@ -0,0 +1,34 @@
+from fastapi.testclient import TestClient
+from app.main import app
+
+client = TestClient(app)
+
+
+def test_case_sensitive_headers_preserves_case():
+    response = client.get(
+        "/ping",
+        headers={"X-Custom-Header": "value", "User-Agent": "test"},
+    )
+    assert response.status_code == 200
+
+
+def test_case_sensitive_headers_no_raw_headers():
+    response = client.get("/ping", headers={})
+    assert response.status_code == 200
+
+
+def test_case_sensitive_headers_post_with_content_type():
+    response = client.post(
+        "/api/v1/visit",
+        json={"url": "https://example.com"},
+        headers={"X-Custom-Header": "value", "Content-Type": "application/json"},
+    )
+    assert response.status_code in (200, 422)
+
+
+def test_valid_headers_filtering():
+    response = client.get(
+        "/api/v1/ping",
+        headers={"User-Agent": "test"},
+    )
+    assert response.status_code == 200

+ 18 - 0
tests/test_ping.py

@@ -0,0 +1,18 @@
+from fastapi.testclient import TestClient
+from app.main import app
+
+client = TestClient(app)
+
+
+def test_web_ping():
+    response = client.get("/ping")
+    assert response.status_code == 200
+    assert response.text == '"pong"'
+
+
+def test_api_ping():
+    response = client.get("/api/v1/ping")
+    assert response.status_code == 200
+    body = response.json()
+    assert body["status"] == "OK"
+    assert body["message"] == "pong"

+ 70 - 0
tests/test_schemas.py

@@ -0,0 +1,70 @@
+from app.models.schemas import VisitRequest, WaitUntilValue, ArasResponse
+from pydantic import ValidationError
+import pytest
+
+
+class TestWaitUntilValue:
+    def test_valid_values(self):
+        for val in ["load", "domcontentloaded", "networkidle", "commit"]:
+            assert val in WaitUntilValue.__args__
+
+    def test_invalid_value_raises_error(self):
+        with pytest.raises(ValidationError):
+            VisitRequest(url="https://example.com", waitUntil="invalid")
+
+
+class TestVisitRequest:
+    def test_minimal_request(self):
+        req = VisitRequest(url="https://example.com")
+        assert str(req.url) == "https://example.com/"
+        assert req.screenshot is False
+        assert req.waitUntil == "load"
+        assert req.proxy is None
+        assert req.proxyAuth is None
+        assert req.incognito is False
+
+    def test_full_request(self):
+        req = VisitRequest(
+            url="https://example.com",
+            screenshot=True,
+            waitUntil="networkidle",
+            proxy="http://proxy:8080",
+            proxyAuth={"username": "user", "password": "pass"},
+            incognito=True,
+        )
+        assert req.screenshot is True
+        assert req.waitUntil == "networkidle"
+        assert req.proxy == "http://proxy:8080"
+        assert req.proxyAuth["username"] == "user"
+        assert req.incognito is True
+
+    def test_invalid_url_raises_error(self):
+        with pytest.raises(ValidationError):
+            VisitRequest(url="not-a-url")
+
+    def test_all_waituntil_values(self):
+        for val in ["load", "domcontentloaded", "networkidle", "commit"]:
+            req = VisitRequest(url="https://example.com", waitUntil=val)
+            assert req.waitUntil == val
+
+
+class TestArasResponse:
+    def test_minimal_response(self):
+        resp = ArasResponse(status="OK", message="test", data=None)
+        assert resp.status == "OK"
+        assert resp.message == "test"
+        assert resp.data is None
+        assert resp.extra is None
+        assert resp.code == "UNKNOWN_CODE"
+
+    def test_full_response(self):
+        resp = ArasResponse(
+            status="OK",
+            message="test",
+            data={"key": "value"},
+            extra={"extra": "data"},
+            code="CUSTOM_CODE",
+        )
+        assert resp.data == {"key": "value"}
+        assert resp.extra == {"extra": "data"}
+        assert resp.code == "CUSTOM_CODE"

+ 13 - 0
tests/test_server.py

@@ -0,0 +1,13 @@
+"""Test trivial para app/server.py"""
+from unittest.mock import patch
+
+
+def test_start_runs_uvicorn():
+    with patch("uvicorn.run") as mock_run:
+        from app.server import start
+        start()
+        mock_run.assert_called_once()
+        call_args = mock_run.call_args[0]
+        call_kwargs = mock_run.call_args[1]
+        assert call_args[0] == "app.main:app"
+        assert call_kwargs["host"] == "0.0.0.0"

+ 18 - 0
tests/test_visit_usecase.py

@@ -0,0 +1,18 @@
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+from app.use_cases.camoufox.visit import VisitUseCase
+
+
+class TestVisitUseCase:
+    @patch("app.use_cases.camoufox.visit.CamoufoxWrapper")
+    def test_execute_calls_wrapper_visit(self, mock_wrapper_class):
+        mock_wrapper = MagicMock()
+        mock_wrapper.visit = AsyncMock(return_value={"html": "<html>", "screenshot": None})
+        mock_wrapper_class.return_value = mock_wrapper
+
+        use_case = VisitUseCase()
+        import asyncio
+        result = asyncio.run(use_case.execute("https://example.com", {}))
+
+        mock_wrapper.visit.assert_called_once_with("https://example.com", {})
+        assert result == {"html": "<html>", "screenshot": None}

+ 96 - 0
tests/test_wrapper.py

@@ -0,0 +1,96 @@
+from unittest.mock import MagicMock, patch
+from app.use_cases.camoufox.wrapper import (
+    CamoufoxWrapper,
+    _detect_headless_mode,
+    _resolve_headless_mode,
+)
+
+
+class TestDetectHeadlessMode:
+    @patch("sys.platform", "darwin")
+    def test_macos_returns_true(self):
+        result = _detect_headless_mode()
+        assert result is True
+
+    @patch("sys.platform", "win32")
+    def test_windows_returns_true(self):
+        result = _detect_headless_mode()
+        assert result is True
+
+    @patch("shutil.which", return_value=None)
+    @patch("sys.platform", "linux")
+    def test_linux_without_xvfb_returns_true(self, mock_which):
+        result = _detect_headless_mode()
+        assert result is True
+
+    @patch("shutil.which", return_value="/usr/bin/Xvfb")
+    @patch("sys.platform", "linux")
+    def test_linux_with_xvfb_returns_virtual(self, mock_which):
+        result = _detect_headless_mode()
+        assert result == "virtual"
+
+
+class TestResolveHeadlessMode:
+    @patch("app.use_cases.camoufox.wrapper.config")
+    def test_env_virtual_returns_virtual(self, mock_config):
+        mock_config.HEADLESS_MODE = "virtual"
+        result = _resolve_headless_mode()
+        assert result == "virtual"
+
+    @patch("app.use_cases.camoufox.wrapper.config")
+    def test_env_true_returns_true(self, mock_config):
+        for val in ["true", "True", "1"]:
+            mock_config.HEADLESS_MODE = val
+            result = _resolve_headless_mode()
+            assert result is True
+
+    @patch("app.use_cases.camoufox.wrapper.config")
+    def test_env_false_returns_false(self, mock_config):
+        for val in ["false", "False", "0"]:
+            mock_config.HEADLESS_MODE = val
+            result = _resolve_headless_mode()
+            assert result is False
+
+    @patch("app.use_cases.camoufox.wrapper.config")
+    @patch("app.use_cases.camoufox.wrapper._detect_headless_mode")
+    def test_env_auto_uses_detection(self, mock_detect, mock_config):
+        mock_config.HEADLESS_MODE = "auto"
+        mock_detect.return_value = True
+        result = _resolve_headless_mode()
+        mock_detect.assert_called_once()
+        assert result is True
+
+
+class TestCamoufoxWrapper:
+    def test_singleton(self):
+        wrapper1 = CamoufoxWrapper()
+        wrapper2 = CamoufoxWrapper()
+        assert wrapper1 is wrapper2
+
+    def test_build_proxy_config_simple(self):
+        wrapper = CamoufoxWrapper()
+        result = wrapper._build_proxy_config("host:8080")
+        assert result["server"] == "http://host:8080"
+
+    def test_build_proxy_config_with_scheme(self):
+        wrapper = CamoufoxWrapper()
+        result = wrapper._build_proxy_config("http://host:8080")
+        assert result["server"] == "http://host:8080"
+
+    def test_build_proxy_config_with_auth(self):
+        wrapper = CamoufoxWrapper()
+        result = wrapper._build_proxy_config(
+            "host:8080", {"username": "user", "password": "pass"}
+        )
+        assert result["server"] == "http://host:8080"
+        assert result["username"] == "user"
+        assert result["password"] == "pass"
+
+    def test_build_proxy_config_with_auth_no_password(self):
+        wrapper = CamoufoxWrapper()
+        result = wrapper._build_proxy_config(
+            "host:8080", {"username": "user"}
+        )
+        assert result["server"] == "http://host:8080"
+        assert result["username"] == "user"
+        assert "password" not in result

+ 294 - 0
tests/test_wrapper_async.py

@@ -0,0 +1,294 @@
+"""
+Tests para cubrir código específico del wrapper que no se ejecuta en tests de integración.
+Usamos mocks para simular el comportamiento de AsyncCamoufox.
+"""
+import pytest
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+from app.use_cases.camoufox.wrapper import CamoufoxWrapper
+
+
+@pytest.mark.asyncio
+class TestCamoufoxWrapperVisit:
+    """Tests del método visit() con mocks para cubrir todas las ramas."""
+
+    @pytest.fixture
+    def mock_browser(self):
+        """Crea un mock de browser con page mockeado."""
+        mock_page = AsyncMock()
+        mock_page.content = AsyncMock(return_value="<html><body>Test</body></html>")
+        mock_page.screenshot = AsyncMock(return_value=b"screenshot_data")
+        
+        mock_browser = AsyncMock()
+        mock_browser.new_page = AsyncMock(return_value=mock_page)
+        
+        return mock_browser, mock_page
+
+    @pytest.fixture
+    def wrapper(self):
+        return CamoufoxWrapper()
+
+    @patch("app.use_cases.camoufox.wrapper.AsyncCamoufox")
+    async def test_visit_with_proxy(self, mock_camoufox_class, wrapper, mock_browser):
+        """Test que cubre la rama de proxy en visit()."""
+        mock_browser_instance, mock_page = mock_browser
+        mock_camoufox_class.return_value.__aenter__.return_value = mock_browser_instance
+        
+        result = await wrapper.visit(
+            "https://example.com",
+            {"proxy": "http://proxy:8080", "screenshot": False}
+        )
+        
+        # Verificar que se llamó con proxy y geoip
+        call_kwargs = mock_camoufox_class.call_args[1]
+        assert call_kwargs["proxy"]["server"] == "http://proxy:8080"
+        assert call_kwargs["geoip"] is True
+        assert result["html"] == "<html><body>Test</body></html>"
+
+    @patch("app.use_cases.camoufox.wrapper.AsyncCamoufox")
+    async def test_visit_with_proxy_auth(self, mock_camoufox_class, wrapper, mock_browser):
+        """Test que cubre proxy con autenticación."""
+        mock_browser_instance, mock_page = mock_browser
+        mock_camoufox_class.return_value.__aenter__.return_value = mock_browser_instance
+        
+        result = await wrapper.visit(
+            "https://example.com",
+            {
+                "proxy": "http://proxy:8080",
+                "proxyAuth": {"username": "user", "password": "pass"},
+                "screenshot": False,
+            }
+        )
+        
+        call_kwargs = mock_camoufox_class.call_args[1]
+        assert call_kwargs["proxy"]["username"] == "user"
+        assert call_kwargs["proxy"]["password"] == "pass"
+
+    @patch("app.use_cases.camoufox.wrapper.AsyncCamoufox")
+    async def test_visit_with_headers(self, mock_camoufox_class, wrapper, mock_browser):
+        """Test que cubre la rama de headers (excluye host y content-length)."""
+        mock_browser_instance, mock_page = mock_browser
+        mock_camoufox_class.return_value.__aenter__.return_value = mock_browser_instance
+        
+        headers = {
+            "user-agent": "TestAgent",
+            "host": "example.com",  # Debería ser eliminado
+            "content-length": "100",  # Debería ser eliminado
+            "x-custom": "value",
+        }
+        
+        result = await wrapper.visit(
+            "https://example.com",
+            {"headers": headers.copy(), "screenshot": False}
+        )
+        
+        # Verificar que se llamó a set_extra_http_headers
+        mock_page.set_extra_http_headers.assert_called_once()
+        called_headers = mock_page.set_extra_http_headers.call_args[0][0]
+        assert "host" not in called_headers
+        assert "content-length" not in called_headers
+        assert "user-agent" in called_headers
+        assert "x-custom" in called_headers
+
+    @patch("app.use_cases.camoufox.wrapper.AsyncCamoufox")
+    async def test_visit_mercadolibre_triggers_hacks(self, mock_camoufox_class, wrapper, mock_browser):
+        """Test que verifica que URLs de MeLi llaman a _handle_mercadolibre."""
+        mock_browser_instance, mock_page = mock_browser
+        
+        # Mockear _handle_mercadolibre para que retorne un HTML específico
+        with patch.object(wrapper, "_handle_mercadolibre", new_callable=AsyncMock) as mock_handle:
+            mock_handle.return_value = "<html><body>MeLi Hackeado</body></html>"
+            mock_camoufox_class.return_value.__aenter__.return_value = mock_browser_instance
+            
+            result = await wrapper.visit(
+                "https://autos.mercadolibre.cl/auto",
+                {"screenshot": False}
+            )
+            
+            mock_handle.assert_called_once()
+            assert result["html"] == "<html><body>MeLi Hackeado</body></html>"
+
+    @patch("app.use_cases.camoufox.wrapper.AsyncCamoufox")
+    async def test_visit_non_mercadolibre_skips_hacks(self, mock_camoufox_class, wrapper, mock_browser):
+        """Test que verifica que URLs normales NO llaman a _handle_mercadolibre."""
+        mock_browser_instance, mock_page = mock_browser
+        mock_camoufox_class.return_value.__aenter__.return_value = mock_browser_instance
+        
+        # Mockear _handle_mercadolibre para verificar que NO se llama
+        with patch.object(wrapper, "_handle_mercadolibre", new_callable=AsyncMock) as mock_handle:
+            result = await wrapper.visit(
+                "https://example.com",
+                {"screenshot": False}
+            )
+            
+            mock_handle.assert_not_called()
+            assert result["html"] == "<html><body>Test</body></html>"
+
+    @patch("app.use_cases.camoufox.wrapper.AsyncCamoufox")
+    async def test_visit_with_screenshot(self, mock_camoufox_class, wrapper, mock_browser):
+        """Test que cubre la rama de screenshot."""
+        mock_browser_instance, mock_page = mock_browser
+        mock_camoufox_class.return_value.__aenter__.return_value = mock_browser_instance
+        
+        result = await wrapper.visit(
+            "https://example.com",
+            {"screenshot": True}
+        )
+        
+        mock_page.screenshot.assert_called_once()
+        assert result["screenshot"] is not None
+
+    @patch("app.use_cases.camoufox.wrapper.AsyncCamoufox")
+    async def test_visit_without_screenshot(self, mock_camoufox_class, wrapper, mock_browser):
+        """Test que verifica que screenshot=False no llama a screenshot()."""
+        mock_browser_instance, mock_page = mock_browser
+        mock_camoufox_class.return_value.__aenter__.return_value = mock_browser_instance
+        
+        result = await wrapper.visit(
+            "https://example.com",
+            {"screenshot": False}
+        )
+        
+        mock_page.screenshot.assert_not_called()
+        assert result["screenshot"] is None
+
+    @patch("app.use_cases.camoufox.wrapper.AsyncCamoufox")
+    async def test_virtual_display_fallback(self, mock_camoufox_class, wrapper, mock_browser):
+        """Test que cubre el fallback de virtual display a headless=True."""
+        mock_browser_instance, mock_page = mock_browser
+        
+        # Primera llamada falla, segunda tiene éxito
+        mock_camoufox_class.side_effect = [
+            Exception("Virtual display failed"),
+            MagicMock(__aenter__=AsyncMock(return_value=mock_browser_instance))
+        ]
+        
+        # Simular que headless era "virtual"
+        with patch("app.use_cases.camoufox.wrapper._resolve_headless_mode", return_value="virtual"):
+            result = await wrapper.visit(
+                "https://example.com",
+                {"screenshot": False}
+            )
+            
+            # Verificar que se llamó dos veces (intento + fallback)
+            assert mock_camoufox_class.call_count == 2
+            # Segunda llamada debería tener headless=True
+            second_call_kwargs = mock_camoufox_class.call_args_list[1][1]
+            assert second_call_kwargs["headless"] is True
+
+    @patch("app.use_cases.camoufox.wrapper.AsyncCamoufox")
+    async def test_visit_with_waituntil(self, mock_camoufox_class, wrapper, mock_browser):
+        """Test que cubre diferentes valores de waitUntil."""
+        mock_browser_instance, mock_page = mock_browser
+        mock_camoufox_class.return_value.__aenter__.return_value = mock_browser_instance
+        
+        for wait_until in ["load", "domcontentloaded", "networkidle", "commit"]:
+            await wrapper.visit(
+                "https://example.com",
+                {"waitUntil": wait_until, "screenshot": False}
+            )
+            
+            mock_page.goto.assert_called()
+            call_kwargs = mock_page.goto.call_args[1]
+            assert call_kwargs["wait_until"] == wait_until
+
+
+@pytest.mark.asyncio
+class TestHandleMercadolibre:
+    """Tests específicos para el método _handle_mercadolibre."""
+
+    @pytest.fixture
+    def wrapper(self):
+        return CamoufoxWrapper()
+
+    @pytest.fixture
+    def mock_page(self):
+        page = AsyncMock()
+        page.content = AsyncMock(return_value="<html><body>Normal</body></html>")
+        page.wait_for_selector = AsyncMock()
+        page.evaluate = AsyncMock()
+        page.click = AsyncMock()
+        page.reload = AsyncMock()
+        return page
+
+    @pytest.fixture
+    def mock_browser(self):
+        browser = AsyncMock()
+        browser.new_page = AsyncMock(return_value=AsyncMock())
+        return browser
+
+    async def test_handle_mercadolibre_captcha_detected(self, wrapper, mock_page, mock_browser):
+        """Test que cubre la rama de captcha 'Para continuar'."""
+        new_page_mock = AsyncMock()
+        new_page_mock.content = AsyncMock(return_value="<html><body>Después de recargar</body></html>")
+        mock_browser.new_page = AsyncMock(return_value=new_page_mock)
+        
+        mock_page.content = AsyncMock(side_effect=[
+            "<html><body>Para continuar</body></html>",
+            "<html><body>Final</body></html>",
+        ])
+        
+        result = await wrapper._handle_mercadolibre(mock_page, "https://mercadolibre.cl", mock_browser)
+        
+        # Verificar que se creó una nueva página
+        mock_browser.new_page.assert_called_once()
+        assert result is not None
+
+    async def test_handle_mercadolibre_rate_limit_detected(self, wrapper, mock_page, mock_browser):
+        """Test que cubre la rama de rate limit 429."""
+        mock_page.reload = AsyncMock()
+        mock_page.content = AsyncMock(side_effect=[
+            "<html><body>Normal</body></html>",
+            '<html><body>{"message":"local_rate_limited","status":429}</body></html>',
+            "<html><body>Después de reload</body></html>",
+            "<html><body>Final</body></html>",
+        ])
+        
+        result = await wrapper._handle_mercadolibre(mock_page, "https://mercadolibre.cl", mock_browser)
+        
+        # Verificar que se llamó a reload
+        mock_page.reload.assert_called_once()
+        assert result is not None
+
+    async def test_handle_mercadolibre_cookie_banner_present(self, wrapper, mock_page, mock_browser):
+        """Test que cubre la rama de banner de cookies."""
+        mock_page.content = AsyncMock(return_value="<html><body>Normal</body></html>")
+        mock_page.wait_for_selector = AsyncMock()
+        mock_page.evaluate = AsyncMock()
+        
+        result = await wrapper._handle_mercadolibre(mock_page, "https://mercadolibre.cl", mock_browser)
+        
+        # Verificar que se llamó a wait_for_selector (al menos una vez)
+        assert mock_page.wait_for_selector.call_count >= 1
+        assert result is not None
+        mock_page.evaluate.assert_called()
+
+    async def test_handle_mercadolibre_cookie_banner_not_present(self, wrapper, mock_page, mock_browser):
+        """Test que cubre la rama donde el banner de cookies no existe."""
+        mock_page.content.return_value = "<html><body>Normal</body></html>"
+        mock_page.wait_for_selector.side_effect = Exception("Timeout")
+        
+        # No debería fallar aunque el banner no exista
+        result = await wrapper._handle_mercadolibre(mock_page, "https://mercadolibre.cl", mock_browser)
+        assert result is not None
+
+    async def test_handle_mercadolibre_buttons_present(self, wrapper, mock_page, mock_browser):
+        """Test que cubre la rama de botones de características y descripción."""
+        mock_page.content.return_value = "<html><body>Normal</body></html>"
+        mock_page.wait_for_selector.return_value = None
+        mock_page.click.return_value = None
+        
+        result = await wrapper._handle_mercadolibre(mock_page, "https://mercadolibre.cl", mock_browser)
+        
+        # Verificar que se intentó hacer click en los botones
+        assert mock_page.wait_for_selector.call_count >= 2
+        assert mock_page.click.call_count >= 2
+
+    async def test_handle_mercadolibre_buttons_not_present(self, wrapper, mock_page, mock_browser):
+        """Test que cubre la rama donde los botones no existen."""
+        mock_page.content.return_value = "<html><body>Normal</body></html>"
+        mock_page.wait_for_selector.side_effect = Exception("Timeout")
+        
+        # No debería fallar aunque los botones no existan
+        result = await wrapper._handle_mercadolibre(mock_page, "https://mercadolibre.cl", mock_browser)
+        assert result is not None