Merge remote-tracking branch 'origin/main' into copilot/fix-mobile-app-login
This commit is contained in:
@@ -5,7 +5,7 @@ Focuses on uncovered lines: 98-99, 121-143, 160-161, 170-171,
|
||||
324-326, 400-402, 436-438.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -15,7 +15,7 @@ from fastapi.testclient import TestClient
|
||||
class TestTestTokenRefreshFailed:
|
||||
"""Cover lines 98-99: token refresh returns non-200."""
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_test_token_refresh_returns_non_200(self, mock_post, client: TestClient):
|
||||
"""Test token refresh returning a failure status hits the error branch."""
|
||||
from app.config import settings
|
||||
@@ -42,8 +42,8 @@ class TestTestTokenRefreshFailed:
|
||||
class TestTestTokenRotation:
|
||||
"""Cover lines 121-143, 160-161: token rotation with .env and DB persist."""
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_token_rotation_env_file_exists(self, mock_post, mock_get, client: TestClient, tmp_path):
|
||||
"""When a new refresh token is received and .env file exists, it should be updated."""
|
||||
from app.config import settings
|
||||
@@ -90,8 +90,8 @@ class TestTestTokenRotation:
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_token_rotation_env_not_existing(self, mock_post, mock_get, client: TestClient):
|
||||
"""Token rotation when .env doesn't exist still succeeds."""
|
||||
from app.config import settings
|
||||
@@ -130,8 +130,8 @@ class TestTestTokenRotation:
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_token_rotation_env_write_failure(self, mock_post, mock_get, client: TestClient):
|
||||
"""Token rotation when .env write fails (lines 142-143) still continues."""
|
||||
from app.config import settings
|
||||
@@ -171,8 +171,8 @@ class TestTestTokenRotation:
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_token_rotation_db_persist_failure(self, mock_post, mock_get, client: TestClient):
|
||||
"""Token rotation when DB persist fails (lines 160-161) still continues."""
|
||||
from app.config import settings
|
||||
@@ -211,8 +211,8 @@ class TestTestTokenRotation:
|
||||
class TestTestTokenUserInfoFailed:
|
||||
"""Cover lines 170-171: user info request fails."""
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_user_info_returns_non_200(self, mock_post, mock_get, client: TestClient):
|
||||
"""Test when user info request fails after successful token refresh."""
|
||||
from app.config import settings
|
||||
@@ -247,8 +247,8 @@ class TestTestTokenUserInfoFailed:
|
||||
class TestTokenRotationEnvAppendLine:
|
||||
"""Cover the branch at line 134 where token line is not found in .env and must be appended."""
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_token_rotation_appends_to_env(self, mock_post, mock_get, client: TestClient, tmp_path):
|
||||
"""When .env exists but doesn't have ONEDRIVE_REFRESH_TOKEN, it should append."""
|
||||
from app.config import settings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Comprehensive unit tests for app/api/onedrive.py module."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -48,8 +48,8 @@ class TestExchangeOneDriveToken:
|
||||
class TestTestOneDriveToken:
|
||||
"""Tests for GET /onedrive/test-token endpoint."""
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
def test_test_token_success(self, mock_get, mock_post):
|
||||
"""Test successful token validation."""
|
||||
from app.config import settings
|
||||
@@ -79,7 +79,7 @@ class TestTestOneDriveToken:
|
||||
# Should return success
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_test_token_not_configured(self, mock_post):
|
||||
"""Test when credentials are not configured."""
|
||||
from app.config import settings
|
||||
@@ -88,7 +88,7 @@ class TestTestOneDriveToken:
|
||||
# Should return error
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_test_token_refresh_failed(self, mock_post):
|
||||
"""Test when token refresh fails."""
|
||||
from app.config import settings
|
||||
@@ -104,8 +104,8 @@ class TestTestOneDriveToken:
|
||||
# Should return error with needs_reauth
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
def test_test_token_user_info_failed(self, mock_get, mock_post):
|
||||
"""Test when user info request fails."""
|
||||
from app.config import settings
|
||||
@@ -128,8 +128,8 @@ class TestTestOneDriveToken:
|
||||
# Should return error
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("builtins.open", create=True)
|
||||
@patch("os.path.exists")
|
||||
def test_test_token_updates_refresh_token(self, mock_exists, mock_open, mock_get, mock_post):
|
||||
@@ -167,8 +167,8 @@ class TestTestOneDriveToken:
|
||||
# Should update refresh token in memory and file
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
def test_test_token_expiration_info(self, mock_get, mock_post):
|
||||
"""Test that expiration info is included."""
|
||||
from app.config import settings
|
||||
@@ -195,7 +195,7 @@ class TestTestOneDriveToken:
|
||||
# token_info should include expiration details
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_test_token_exception_handling(self, mock_post):
|
||||
"""Test handling of exceptions."""
|
||||
from app.config import settings
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for the saved searches API (app/api/saved_searches.py)."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models import SavedSearch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test data constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OWNER = "test_user@example.com"
|
||||
_OTHER_OWNER = "other_user@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixture helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def int_engine():
|
||||
"""In-memory SQLite engine for integration tests."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def int_session(int_engine):
|
||||
"""DB session scoped to one test."""
|
||||
Session = sessionmaker(bind=int_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def _make_client(int_engine, owner_id: str = _OWNER):
|
||||
"""Return a TestClient with *owner_id* injected as the authenticated user."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.main import app
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=int_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
with patch("app.api.saved_searches._get_user_id", return_value=owner_id):
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def int_client(int_engine):
|
||||
"""TestClient authenticated as _OWNER."""
|
||||
yield from _make_client(int_engine, _OWNER)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestSavedSearchesAPI:
|
||||
"""Tests for Saved Searches endpoints."""
|
||||
|
||||
def test_list_saved_searches_empty(self, int_client):
|
||||
"""No saved searches returns empty list."""
|
||||
resp = int_client.get("/api/saved-searches")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_create_saved_search(self, int_client):
|
||||
"""Create a saved search and verify the response."""
|
||||
payload = {"name": "My Invoices", "filters": {"tags": "invoice", "document_type": "Invoice"}}
|
||||
resp = int_client.post("/api/saved-searches", json=payload)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "My Invoices"
|
||||
assert data["filters"] == {"tags": "invoice", "document_type": "Invoice"}
|
||||
assert "id" in data
|
||||
|
||||
def test_create_saved_search_invalid_filters(self, int_client):
|
||||
"""Creating with invalid filters returns 422."""
|
||||
# Missing filters parameter (or empty after sanitization)
|
||||
payload = {"name": "My Invoices", "filters": {}}
|
||||
resp = int_client.post("/api/saved-searches", json=payload)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# Invalid filters format
|
||||
payload2 = {"name": "My Invoices", "filters": "not_a_dict"}
|
||||
resp2 = int_client.post("/api/saved-searches", json=payload2)
|
||||
assert resp2.status_code == 422
|
||||
|
||||
def test_create_saved_search_duplicate(self, int_client):
|
||||
"""Creating a duplicate named search returns 409."""
|
||||
payload = {"name": "Duplicate", "filters": {"q": "test"}}
|
||||
int_client.post("/api/saved-searches", json=payload)
|
||||
resp = int_client.post("/api/saved-searches", json=payload)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_create_saved_search_limit(self, int_client, int_session):
|
||||
"""Exceeding MAX_SAVED_SEARCHES_PER_USER returns 409."""
|
||||
# Create 50 searches using the API to ensure they are visible
|
||||
for i in range(50):
|
||||
resp = int_client.post("/api/saved-searches", json={"name": f"Search LIMIT {i}", "filters": {"q": "test"}})
|
||||
assert resp.status_code == 201
|
||||
|
||||
payload = {"name": "One too many", "filters": {"q": "test"}}
|
||||
resp = int_client.post("/api/saved-searches", json=payload)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_update_saved_search(self, int_client):
|
||||
"""Update an existing saved search."""
|
||||
payload = {"name": "Original Name", "filters": {"q": "test"}}
|
||||
created = int_client.post("/api/saved-searches", json=payload).json()
|
||||
search_id = created["id"]
|
||||
|
||||
update_payload = {"name": "Updated Name", "filters": {"tags": "new"}}
|
||||
resp = int_client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "Updated Name"
|
||||
assert data["filters"] == {"tags": "new"}
|
||||
|
||||
def test_update_saved_search_not_found(self, int_client):
|
||||
"""Updating a non-existent search returns 404."""
|
||||
update_payload = {"name": "Updated Name"}
|
||||
resp = int_client.put("/api/saved-searches/999", json=update_payload)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_saved_search_duplicate_name(self, int_client):
|
||||
"""Updating name to an existing search name returns 409."""
|
||||
payload1 = {"name": "Search 1", "filters": {"q": "a"}}
|
||||
payload2 = {"name": "Search 2", "filters": {"q": "b"}}
|
||||
int_client.post("/api/saved-searches", json=payload1)
|
||||
created2 = int_client.post("/api/saved-searches", json=payload2).json()
|
||||
search2_id = created2["id"]
|
||||
|
||||
update_payload = {"name": "Search 1"}
|
||||
resp = int_client.put(f"/api/saved-searches/{search2_id}", json=update_payload)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_delete_saved_search(self, int_client, int_session):
|
||||
"""Delete an existing search."""
|
||||
payload = {"name": "To be deleted", "filters": {"q": "test"}}
|
||||
created = int_client.post("/api/saved-searches", json=payload).json()
|
||||
search_id = created["id"]
|
||||
|
||||
resp = int_client.delete(f"/api/saved-searches/{search_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
assert int_session.query(SavedSearch).filter(SavedSearch.id == search_id).first() is None
|
||||
|
||||
def test_delete_saved_search_not_found(self, int_client):
|
||||
"""Deleting a non-existent search returns 404."""
|
||||
resp = int_client.delete("/api/saved-searches/999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_other_users_searches_isolated(self, int_engine, int_session):
|
||||
"""Users only see and can only modify their own saved searches."""
|
||||
int_session.add(SavedSearch(user_id=_OTHER_OWNER, name="Other Search", filters='{"q": "test"}'))
|
||||
int_session.commit()
|
||||
|
||||
client = next(_make_client(int_engine, _OWNER))
|
||||
resp = client.get("/api/saved-searches")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 0
|
||||
|
||||
other_search = int_session.query(SavedSearch).first()
|
||||
resp = client.put(f"/api/saved-searches/{other_search.id}", json={"name": "Hacked"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
resp = client.delete(f"/api/saved-searches/{other_search.id}")
|
||||
assert resp.status_code == 404
|
||||
@@ -417,14 +417,20 @@ class TestOneDriveIntegration:
|
||||
|
||||
def test_onedrive_token_refresh_and_user_info(self, original_env: dict) -> None:
|
||||
"""Validate token refresh and user info retrieval."""
|
||||
import requests
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
token = self._get_access_token(original_env)
|
||||
resp = requests.get(
|
||||
"https://graph.microsoft.com/v1.0/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
async def _test():
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
return await client.get(
|
||||
"https://graph.microsoft.com/v1.0/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
resp = asyncio.run(_test())
|
||||
assert resp.status_code == 200, f"OneDrive user info failed: {resp.text}"
|
||||
|
||||
def test_onedrive_upload_download_delete(self, original_env: dict) -> None:
|
||||
|
||||
@@ -143,11 +143,72 @@ class TestExtractMetadataFromFile:
|
||||
|
||||
result = extract_metadata_from_file(str(file_path))
|
||||
|
||||
# Check that the leading slash is stripped and keys/values match
|
||||
assert result.get("Title") == "Test Title"
|
||||
assert result.get("Author") == "Test Author"
|
||||
assert result.get("Subject") == "Test Document"
|
||||
assert result.get("Keywords") == "test, metadata, pypdf"
|
||||
# Keys are mapped to application-specific names
|
||||
assert result.get("filename") == "Test Title"
|
||||
assert result.get("absender") == "Test Author"
|
||||
assert result.get("document_type") == "Test Document"
|
||||
assert result.get("tags") == "test, metadata, pypdf"
|
||||
|
||||
def test_extracts_embedded_metadata_from_pdf(self, tmp_path):
|
||||
"""Test that embedded PDF metadata is mapped to application-specific keys."""
|
||||
import pypdf
|
||||
|
||||
file_path = tmp_path / "mapped.pdf"
|
||||
|
||||
writer = pypdf.PdfWriter()
|
||||
writer.add_blank_page(width=100, height=100)
|
||||
writer.add_metadata(
|
||||
{
|
||||
"/Title": "Invoice 2024",
|
||||
"/Author": "Acme Corp",
|
||||
"/Subject": "invoice",
|
||||
"/Keywords": "finance, billing",
|
||||
}
|
||||
)
|
||||
with open(file_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
result = extract_metadata_from_file(str(file_path))
|
||||
|
||||
# Verify the PDF-to-app key mapping
|
||||
assert result["filename"] == "Invoice 2024"
|
||||
assert result["absender"] == "Acme Corp"
|
||||
assert result["document_type"] == "invoice"
|
||||
assert result["tags"] == "finance, billing"
|
||||
|
||||
def test_pdf_metadata_does_not_overwrite_json(self, tmp_path):
|
||||
"""Test that JSON metadata takes precedence over embedded PDF metadata."""
|
||||
import pypdf
|
||||
|
||||
file_path = tmp_path / "dual.pdf"
|
||||
|
||||
# Create a PDF with embedded metadata
|
||||
writer = pypdf.PdfWriter()
|
||||
writer.add_blank_page(width=100, height=100)
|
||||
writer.add_metadata(
|
||||
{
|
||||
"/Title": "PDF Title",
|
||||
"/Author": "PDF Author",
|
||||
"/Subject": "PDF Subject",
|
||||
"/Keywords": "pdf, keywords",
|
||||
}
|
||||
)
|
||||
with open(file_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
# Create a companion JSON file that sets some overlapping fields
|
||||
json_metadata = {"filename": "JSON Filename", "absender": "JSON Author"}
|
||||
json_path = tmp_path / "dual.json"
|
||||
json_path.write_text(json.dumps(json_metadata))
|
||||
|
||||
result = extract_metadata_from_file(str(file_path))
|
||||
|
||||
# JSON values must not be overwritten by PDF metadata
|
||||
assert result["filename"] == "JSON Filename"
|
||||
assert result["absender"] == "JSON Author"
|
||||
# Fields missing from JSON are filled from PDF metadata
|
||||
assert result["document_type"] == "PDF Subject"
|
||||
assert result["tags"] == "pdf, keywords"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
+208
-105
@@ -2,10 +2,10 @@
|
||||
Tests for URL-based file upload functionality
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -165,17 +165,24 @@ class TestURLUploadValidation:
|
||||
class TestURLUploadEndpoint:
|
||||
"""Integration tests for URL upload endpoint"""
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_requires_authentication(self, mock_process_document, mock_requests_get, client, monkeypatch):
|
||||
def test_process_url_requires_authentication(self, mock_process_document, mock_stream, client, monkeypatch):
|
||||
"""Test that endpoint requires authentication when auth is enabled"""
|
||||
# Mock successful download to prevent actual HTTP requests
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF content"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF content"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock Celery task
|
||||
mock_task = Mock()
|
||||
@@ -192,17 +199,24 @@ class TestURLUploadEndpoint:
|
||||
# (like no mocking). We're just checking the endpoint exists and is reachable.
|
||||
assert response.status_code != 404 # Endpoint should exist
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_success(self, mock_process_document, mock_requests_get, client, tmp_path):
|
||||
def test_process_url_success(self, mock_process_document, mock_stream, client, tmp_path):
|
||||
"""Test successful URL processing"""
|
||||
# Mock successful download
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF content here"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF content here"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock Celery task
|
||||
mock_task = Mock()
|
||||
@@ -219,8 +233,8 @@ class TestURLUploadEndpoint:
|
||||
assert "filename" in data
|
||||
assert "size" in data
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
def test_process_url_blocks_private_ip(self, mock_requests_get, client):
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_blocks_private_ip(self, mock_stream, client):
|
||||
"""Test that private IPs are blocked"""
|
||||
response = client.post("/api/process-url", json={"url": "http://192.168.1.1/file.pdf"})
|
||||
|
||||
@@ -229,10 +243,10 @@ class TestURLUploadEndpoint:
|
||||
assert "private/internal" in data["detail"]
|
||||
|
||||
# Should not make HTTP request
|
||||
mock_requests_get.assert_not_called()
|
||||
mock_stream.assert_not_called()
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
def test_process_url_blocks_localhost(self, mock_requests_get, client):
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_blocks_localhost(self, mock_stream, client):
|
||||
"""Test that localhost is blocked"""
|
||||
response = client.post("/api/process-url", json={"url": "http://localhost/file.pdf"})
|
||||
|
||||
@@ -241,10 +255,10 @@ class TestURLUploadEndpoint:
|
||||
assert "private/internal" in data["detail"]
|
||||
|
||||
# Should not make HTTP request
|
||||
mock_requests_get.assert_not_called()
|
||||
mock_stream.assert_not_called()
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
def test_process_url_blocks_metadata_endpoint(self, mock_requests_get, client):
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_blocks_metadata_endpoint(self, mock_stream, client):
|
||||
"""Test that cloud metadata endpoints are blocked"""
|
||||
response = client.post("/api/process-url", json={"url": "http://169.254.169.254/latest/meta-data/"})
|
||||
|
||||
@@ -254,17 +268,20 @@ class TestURLUploadEndpoint:
|
||||
assert "metadata" in data["detail"] or "private" in data["detail"]
|
||||
|
||||
# Should not make HTTP request
|
||||
mock_requests_get.assert_not_called()
|
||||
mock_stream.assert_not_called()
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
def test_process_url_invalid_file_type(self, mock_requests_get, client):
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_invalid_file_type(self, mock_stream, client):
|
||||
"""Test that invalid file types are rejected"""
|
||||
# Mock response with executable content-type
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/x-executable"}
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
response = client.post("/api/process-url", json={"url": "https://example.com/malware.exe"})
|
||||
|
||||
@@ -272,21 +289,24 @@ class TestURLUploadEndpoint:
|
||||
data = response.json()
|
||||
assert "Unsupported file type" in data["detail"]
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_file_too_large_by_header(self, mock_process_document, mock_requests_get, client):
|
||||
def test_process_url_file_too_large_by_header(self, mock_process_document, mock_stream, client):
|
||||
"""Test that files too large are rejected based on Content-Length header"""
|
||||
from app.config import settings
|
||||
|
||||
# Mock response with large content-length
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Length": str(settings.max_upload_size + 1000),
|
||||
}
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
response = client.post("/api/process-url", json={"url": "https://example.com/huge.pdf"})
|
||||
|
||||
@@ -297,10 +317,10 @@ class TestURLUploadEndpoint:
|
||||
# Should not process document
|
||||
mock_process_document.delay.assert_not_called()
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
def test_process_url_timeout_error(self, mock_requests_get, client):
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_timeout_error(self, mock_stream, client):
|
||||
"""Test handling of timeout errors"""
|
||||
mock_requests_get.side_effect = requests.exceptions.Timeout("Request timed out")
|
||||
mock_stream.side_effect = httpx.TimeoutException("Request timed out")
|
||||
|
||||
response = client.post("/api/process-url", json={"url": "https://example.com/slow.pdf"})
|
||||
|
||||
@@ -308,10 +328,10 @@ class TestURLUploadEndpoint:
|
||||
data = response.json()
|
||||
assert "timeout" in data["detail"].lower()
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
def test_process_url_connection_error(self, mock_requests_get, client):
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_connection_error(self, mock_stream, client):
|
||||
"""Test handling of connection errors"""
|
||||
mock_requests_get.side_effect = requests.exceptions.ConnectionError("Failed to connect")
|
||||
mock_stream.side_effect = httpx.ConnectError("Failed to connect")
|
||||
|
||||
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
||||
|
||||
@@ -319,15 +339,16 @@ class TestURLUploadEndpoint:
|
||||
data = response.json()
|
||||
assert "connect" in data["detail"].lower()
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
def test_process_url_http_error_404(self, mock_requests_get, client):
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_http_error_404(self, mock_stream, client):
|
||||
"""Test handling of HTTP 404 errors"""
|
||||
mock_response = Mock()
|
||||
# When raising HTTPStatusError, httpx requires request and response arguments
|
||||
# For our code, we just need it to hit the exception handler and check status code
|
||||
mock_request = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
"404 Not Found", response=mock_response
|
||||
)
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_stream.side_effect = httpx.HTTPStatusError("404 Not Found", request=mock_request, response=mock_response)
|
||||
|
||||
response = client.post("/api/process-url", json={"url": "https://example.com/notfound.pdf"})
|
||||
|
||||
@@ -335,17 +356,24 @@ class TestURLUploadEndpoint:
|
||||
data = response.json()
|
||||
assert "HTTP error" in data["detail"]
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_with_custom_filename(self, mock_process_document, mock_requests_get, client, tmp_path):
|
||||
def test_process_url_with_custom_filename(self, mock_process_document, mock_stream, client, tmp_path):
|
||||
"""Test URL upload with custom filename"""
|
||||
# Mock successful download
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF content"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF content"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock Celery task
|
||||
mock_task = Mock()
|
||||
@@ -361,17 +389,24 @@ class TestURLUploadEndpoint:
|
||||
data = response.json()
|
||||
assert data["filename"] == "my-document.pdf"
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_extracts_filename_from_url(self, mock_process_document, mock_requests_get, client, tmp_path):
|
||||
def test_process_url_extracts_filename_from_url(self, mock_process_document, mock_stream, client, tmp_path):
|
||||
"""Test that filename is extracted from URL when not provided"""
|
||||
# Mock successful download
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF content"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF content"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock Celery task
|
||||
mock_task = Mock()
|
||||
@@ -386,9 +421,9 @@ class TestURLUploadEndpoint:
|
||||
# Should extract "annual-report.pdf" from URL
|
||||
assert "annual-report" in data["filename"]
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_file_size_during_download(self, mock_process_document, mock_requests_get, client):
|
||||
def test_process_url_file_size_during_download(self, mock_process_document, mock_stream, client):
|
||||
"""Test that file size is checked during download"""
|
||||
from app.config import settings
|
||||
|
||||
@@ -396,12 +431,19 @@ class TestURLUploadEndpoint:
|
||||
large_chunk = b"x" * (settings.max_upload_size + 1000)
|
||||
|
||||
# Mock response without Content-Length header
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf"} # No Content-Length
|
||||
mock_response.iter_content = Mock(return_value=[large_chunk])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield large_chunk
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
response = client.post("/api/process-url", json={"url": "https://example.com/big.pdf"})
|
||||
|
||||
@@ -412,10 +454,10 @@ class TestURLUploadEndpoint:
|
||||
# Should not process document
|
||||
mock_process_document.delay.assert_not_called()
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
def test_process_url_request_exception(self, mock_requests_get, client):
|
||||
"""Test handling of generic RequestException"""
|
||||
mock_requests_get.side_effect = requests.exceptions.RequestException("Generic request error")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_request_exception(self, mock_stream, client):
|
||||
"""Test handling of generic RequestError"""
|
||||
mock_stream.side_effect = httpx.RequestError("Generic request error")
|
||||
|
||||
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
||||
|
||||
@@ -423,16 +465,23 @@ class TestURLUploadEndpoint:
|
||||
data = response.json()
|
||||
assert "Failed to download file" in data["detail"]
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
def test_process_url_oserror_during_save(self, mock_requests_get, client, tmp_path, monkeypatch):
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_oserror_during_save(self, mock_stream, client, tmp_path, monkeypatch):
|
||||
"""Test handling of OSError when saving file"""
|
||||
# Mock successful download
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock workdir to a non-existent path to trigger OSError
|
||||
from app.config import settings
|
||||
@@ -450,17 +499,24 @@ class TestURLUploadEndpoint:
|
||||
# Restore original workdir
|
||||
monkeypatch.setattr(settings, "workdir", original_workdir)
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_unexpected_exception(self, mock_process_document, mock_requests_get, client):
|
||||
def test_process_url_unexpected_exception(self, mock_process_document, mock_stream, client):
|
||||
"""Test handling of unexpected exceptions"""
|
||||
# Mock successful download but process_document.delay raises unexpected error
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock process_document.delay to raise an unexpected exception
|
||||
mock_process_document.delay.side_effect = RuntimeError("Unexpected processing error")
|
||||
@@ -471,17 +527,24 @@ class TestURLUploadEndpoint:
|
||||
data = response.json()
|
||||
assert "Unexpected error" in data["detail"]
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_filename_without_extension(self, mock_process_document, mock_requests_get, client):
|
||||
def test_process_url_filename_without_extension(self, mock_process_document, mock_stream, client):
|
||||
"""Test that files without extensions are handled correctly"""
|
||||
# Mock successful download
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock Celery task
|
||||
mock_task = Mock()
|
||||
@@ -496,17 +559,24 @@ class TestURLUploadEndpoint:
|
||||
# Should still work, just without extension
|
||||
assert data["task_id"] == "test-task-id"
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_empty_path_uses_download(self, mock_process_document, mock_requests_get, client):
|
||||
def test_process_url_empty_path_uses_download(self, mock_process_document, mock_stream, client):
|
||||
"""Test that empty URL path defaults to 'download' filename"""
|
||||
# Mock successful download
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock Celery task
|
||||
mock_task = Mock()
|
||||
@@ -560,17 +630,24 @@ class TestURLUploadEndpoint:
|
||||
# Link-local address
|
||||
assert is_private_ip("169.254.1.1") is True
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_sanitizes_dangerous_filename(self, mock_process_document, mock_requests_get, client):
|
||||
def test_process_url_sanitizes_dangerous_filename(self, mock_process_document, mock_stream, client):
|
||||
"""Test that dangerous filenames are sanitized"""
|
||||
# Mock successful download
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock Celery task
|
||||
mock_task = Mock()
|
||||
@@ -671,18 +748,25 @@ class TestURLUploadCoverageGaps:
|
||||
assert validate_file_type("", "filename_without_extension") is False
|
||||
|
||||
@patch("app.api.url_upload.sanitize_filename", return_value="")
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_sanitize_filename_returns_empty(
|
||||
self, mock_process_document, mock_requests_get, mock_sanitize, client
|
||||
self, mock_process_document, mock_stream, mock_sanitize, client
|
||||
):
|
||||
"""Test that when sanitize_filename returns empty string, filename defaults to 'download' (line 177)"""
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF content"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF content"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
mock_task = Mock()
|
||||
mock_task.id = "test-task-id-sanitize"
|
||||
@@ -695,17 +779,26 @@ class TestURLUploadCoverageGaps:
|
||||
# When sanitize_filename returns "", safe_filename defaults to "download"
|
||||
assert data["filename"] == "download"
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_skips_empty_chunks(self, mock_process_document, mock_requests_get, client):
|
||||
def test_process_url_skips_empty_chunks(self, mock_process_document, mock_stream, client):
|
||||
"""Test that empty bytes chunks are skipped during download (line 234->233 branch)"""
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf"}
|
||||
# Mix empty bytes (falsy) with real content - covers the `if chunk:` False branch
|
||||
mock_response.iter_content = Mock(return_value=[b"", b"PDF content", b""])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b""
|
||||
yield b"PDF content"
|
||||
yield b""
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
mock_task = Mock()
|
||||
mock_task.id = "test-task-id-chunks"
|
||||
@@ -719,9 +812,9 @@ class TestURLUploadCoverageGaps:
|
||||
|
||||
@patch("app.api.url_upload.os.remove")
|
||||
@patch("app.api.url_upload.os.path.exists", return_value=True)
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_oserror_cleanup_removes_existing_file(
|
||||
self, mock_requests_get, mock_exists, mock_remove, client, tmp_path, monkeypatch
|
||||
self, mock_stream, mock_exists, mock_remove, client, tmp_path, monkeypatch
|
||||
):
|
||||
"""Test OSError handler removes the partial file when it exists (line 285)"""
|
||||
import os
|
||||
@@ -735,12 +828,19 @@ class TestURLUploadCoverageGaps:
|
||||
|
||||
monkeypatch.setattr(settings, "workdir", str(non_existent))
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
||||
|
||||
@@ -750,14 +850,17 @@ class TestURLUploadCoverageGaps:
|
||||
mock_remove.assert_called_once()
|
||||
|
||||
@patch("app.api.url_upload.validate_file_type", side_effect=ValueError("unexpected internal error"))
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
def test_process_url_unexpected_exception_with_no_file_created(self, mock_requests_get, mock_validate, client):
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
def test_process_url_unexpected_exception_with_no_file_created(self, mock_stream, mock_validate, client):
|
||||
"""Test unexpected exception before target_path is assigned; no file cleanup attempted (line 291->293)"""
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf"}
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user