Merge remote-tracking branch 'origin/main' into copilot/fix-mobile-app-login
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
2026-03-16T10:45:13Z
|
2026-03-16T11:41:49Z
|
||||||
|
|||||||
@@ -10,6 +10,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
<!-- version list -->
|
<!-- version list -->
|
||||||
|
|
||||||
|
## v0.148.0 (2026-03-16)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Improve docstring and comment clarity in extract_metadata_from_file
|
||||||
|
([`f2255f9`](https://github.com/christianlouis/DocuElevate/commit/f2255f9a1c28f138eae7836e9cf2df97f03b7b17))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **tasks**: Extract and map embedded PDF metadata in upload_to_email
|
||||||
|
([`9d6bfde`](https://github.com/christianlouis/DocuElevate/commit/9d6bfde2882fe74356bef8b64fb79bfa99870f56))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.147.3 (2026-03-16)
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`ca2d023`](https://github.com/christianlouis/DocuElevate/commit/ca2d023d8130000fb82482fb8fbe0d7e218c94fb))
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
- **onedrive**: Use async httpx for token refresh
|
||||||
|
([`8279795`](https://github.com/christianlouis/DocuElevate/commit/827979598eaeab765a1d24e6011deb26fc804b95))
|
||||||
|
|
||||||
|
- **onedrive**: Use async httpx for token refresh
|
||||||
|
([`2471921`](https://github.com/christianlouis/DocuElevate/commit/24719212042a65ed4a0d99776c6b3974ef97f0e8))
|
||||||
|
|
||||||
|
- **onedrive**: Use async httpx for token refresh
|
||||||
|
([`d1f64eb`](https://github.com/christianlouis/DocuElevate/commit/d1f64ebfba6bb353ea6f75e1535a42fd26a8fe0a))
|
||||||
|
|
||||||
|
- **onedrive**: Use async httpx for token refresh
|
||||||
|
([`7242f3c`](https://github.com/christianlouis/DocuElevate/commit/7242f3c168396aa5400fd46ad531ede93024b467))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.147.2 (2026-03-16)
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`46c4031`](https://github.com/christianlouis/DocuElevate/commit/46c403127641c1b5c729ff69ab5505efa5c9b54d))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`66fdb11`](https://github.com/christianlouis/DocuElevate/commit/66fdb11e39bc63f5a1d2b652649fd39d2a7e7469))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`0f31216`](https://github.com/christianlouis/DocuElevate/commit/0f312160bcb2e46e29c90dd055c4ebc9aaf01ad4))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`5734df2`](https://github.com/christianlouis/DocuElevate/commit/5734df2d5046158a23b987fdc4235d3f3f6b4042))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`46c4031`](https://github.com/christianlouis/DocuElevate/commit/46c403127641c1b5c729ff69ab5505efa5c9b54d))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`0f31216`](https://github.com/christianlouis/DocuElevate/commit/0f312160bcb2e46e29c90dd055c4ebc9aaf01ad4))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`5734df2`](https://github.com/christianlouis/DocuElevate/commit/5734df2d5046158a23b987fdc4235d3f3f6b4042))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`46c4031`](https://github.com/christianlouis/DocuElevate/commit/46c403127641c1b5c729ff69ab5505efa5c9b54d))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`5734df2`](https://github.com/christianlouis/DocuElevate/commit/5734df2d5046158a23b987fdc4235d3f3f6b4042))
|
||||||
|
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
DocuElevate Build Information
|
DocuElevate Build Information
|
||||||
==============================
|
==============================
|
||||||
Version: 0.147.1
|
Version: 0.148.0
|
||||||
Build Date: 2026-03-16T10:45:13Z
|
Build Date: 2026-03-16T11:41:49Z
|
||||||
Git Commit: fd15c3666547405bb0a3af37e98be4727ff635bb
|
Git Commit: 1b1cbfce3914277bb8f982d78ef11890f34c9c04
|
||||||
Git Short SHA: fd15c36
|
Git Short SHA: 1b1cbfc
|
||||||
Git Branch: main
|
Git Branch: main
|
||||||
Commit Date: 2026-03-16T11:44:51+01:00
|
Commit Date: 2026-03-16T12:41:28+01:00
|
||||||
Build Timestamp: 2026-03-16T10:45:13Z
|
Build Timestamp: 2026-03-16T11:41:49Z
|
||||||
==============================
|
==============================
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
# Module-level dependency singleton to satisfy Ruff B008 while maintaining default values for manual calls (e.g. in decorators).
|
||||||
|
_db_dep = Depends(get_db)
|
||||||
|
DbSession = Annotated[Session, _db_dep]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/audit-logs")
|
@router.get("/audit-logs")
|
||||||
@require_login
|
@require_login
|
||||||
async def list_audit_logs(
|
async def list_audit_logs(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: DbSession,
|
db: DbSession = _db_dep,
|
||||||
action: Annotated[str | None, Query(description="Filter by action (exact match)")] = None,
|
action: Annotated[str | None, Query(description="Filter by action (exact match)")] = None,
|
||||||
user: Annotated[str | None, Query(description="Filter by username")] = None,
|
user: Annotated[str | None, Query(description="Filter by username")] = None,
|
||||||
resource_type: Annotated[str | None, Query(description="Filter by resource type")] = None,
|
resource_type: Annotated[str | None, Query(description="Filter by resource type")] = None,
|
||||||
@@ -73,7 +75,7 @@ async def list_audit_logs(
|
|||||||
@require_login
|
@require_login
|
||||||
async def list_distinct_actions(
|
async def list_distinct_actions(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: DbSession,
|
db: DbSession = _db_dep,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Return the distinct action values present in the audit log."""
|
"""Return the distinct action values present in the audit log."""
|
||||||
from app.models import AuditLog
|
from app.models import AuditLog
|
||||||
@@ -86,7 +88,7 @@ async def list_distinct_actions(
|
|||||||
@require_login
|
@require_login
|
||||||
async def list_distinct_users(
|
async def list_distinct_users(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: DbSession,
|
db: DbSession = _db_dep,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Return the distinct user values present in the audit log."""
|
"""Return the distinct user values present in the audit log."""
|
||||||
from app.models import AuditLog
|
from app.models import AuditLog
|
||||||
|
|||||||
+61
-23
@@ -1218,6 +1218,66 @@ def download_file(
|
|||||||
raise HTTPException(status_code=500, detail=f"Error downloading file: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Error downloading file: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_upload_file_chunks(file: UploadFile, target_path: str, max_size: int) -> int:
|
||||||
|
"""Save an uploaded file in chunks and enforce the maximum size limit."""
|
||||||
|
try:
|
||||||
|
written_size = 0
|
||||||
|
with open(target_path, "wb") as f:
|
||||||
|
chunk_size = 65536 # 64 KB chunks
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
written_size += len(chunk)
|
||||||
|
if written_size > max_size:
|
||||||
|
# Exceeded limit mid-stream; clean up and reject
|
||||||
|
f.close()
|
||||||
|
os.remove(target_path)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"File too large: exceeded {max_size} bytes during upload. "
|
||||||
|
f"See SECURITY_AUDIT.md for configuration details.",
|
||||||
|
)
|
||||||
|
f.write(chunk)
|
||||||
|
return written_size
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if os.path.exists(target_path):
|
||||||
|
os.remove(target_path)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: str) -> dict | None:
|
||||||
|
"""Check for an exact duplicate of the uploaded file and return a warning if found."""
|
||||||
|
if not settings.enable_deduplication:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
filehash = hash_file(target_path)
|
||||||
|
existing = (
|
||||||
|
db.query(FileRecord)
|
||||||
|
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(False))
|
||||||
|
.order_by(FileRecord.id.asc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
logger.info(f"Exact duplicate detected on upload: '{safe_filename}' matches file ID {existing.id}")
|
||||||
|
return {
|
||||||
|
"duplicate_type": "exact",
|
||||||
|
"original_file_id": existing.id,
|
||||||
|
"original_filename": existing.original_filename,
|
||||||
|
"message": (
|
||||||
|
"This file appears to be an exact duplicate of an already-processed document. "
|
||||||
|
"It will still be queued but will be flagged as a duplicate."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Duplicate check failed for uploaded file '{safe_filename}': {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ui-upload")
|
@router.post("/ui-upload")
|
||||||
@require_login
|
@require_login
|
||||||
async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...)):
|
async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...)):
|
||||||
@@ -1385,29 +1445,7 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
|||||||
# Check for exact duplicates (same SHA-256 hash) before returning.
|
# Check for exact duplicates (same SHA-256 hash) before returning.
|
||||||
# This gives the caller an immediate warning without waiting for the pipeline.
|
# This gives the caller an immediate warning without waiting for the pipeline.
|
||||||
# Only performed when deduplication is enabled in settings.
|
# Only performed when deduplication is enabled in settings.
|
||||||
exact_duplicate_warning = None
|
exact_duplicate_warning = _check_for_exact_duplicate(db, target_path, safe_filename)
|
||||||
if settings.enable_deduplication:
|
|
||||||
try:
|
|
||||||
filehash = hash_file(target_path)
|
|
||||||
existing = (
|
|
||||||
db.query(FileRecord)
|
|
||||||
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(False))
|
|
||||||
.order_by(FileRecord.id.asc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
exact_duplicate_warning = {
|
|
||||||
"duplicate_type": "exact",
|
|
||||||
"original_file_id": existing.id,
|
|
||||||
"original_filename": existing.original_filename,
|
|
||||||
"message": (
|
|
||||||
"This file appears to be an exact duplicate of an already-processed document. "
|
|
||||||
"It will still be queued but will be flagged as a duplicate."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
logger.info(f"Exact duplicate detected on upload: '{safe_filename}' matches file ID {existing.id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Duplicate check failed for uploaded file '{safe_filename}': {e}")
|
|
||||||
|
|
||||||
response: dict = {
|
response: dict = {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
|
|||||||
+21
-19
@@ -6,7 +6,7 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
import requests
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -92,17 +92,18 @@ async def test_onedrive_token(request: Request):
|
|||||||
"scope": "offline_access Files.ReadWrite",
|
"scope": "offline_access Files.ReadWrite",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(token_url, data=refresh_data, timeout=settings.http_request_timeout)
|
async with httpx.AsyncClient(timeout=settings.http_request_timeout) as client:
|
||||||
|
response = await client.post(token_url, data=refresh_data)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"Failed to refresh OneDrive token: {response.text}")
|
logger.error(f"Failed to refresh OneDrive token: {response.text}")
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "Refresh token has expired or is invalid",
|
"message": "Refresh token has expired or is invalid",
|
||||||
"needs_reauth": True,
|
"needs_reauth": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
token_data = response.json()
|
token_data = response.json()
|
||||||
access_token = token_data.get("access_token")
|
access_token = token_data.get("access_token")
|
||||||
expires_in = token_data.get("expires_in", 3600) # Default to 1 hour if not specified
|
expires_in = token_data.get("expires_in", 3600) # Default to 1 hour if not specified
|
||||||
|
|
||||||
@@ -139,17 +140,18 @@ async def test_onedrive_token(request: Request):
|
|||||||
user_info_url = "https://graph.microsoft.com/v1.0/me"
|
user_info_url = "https://graph.microsoft.com/v1.0/me"
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
user_response = requests.get(user_info_url, headers=headers, timeout=settings.http_request_timeout)
|
async with httpx.AsyncClient(timeout=settings.http_request_timeout) as client:
|
||||||
|
user_response = await client.get(user_info_url, headers=headers)
|
||||||
|
|
||||||
if user_response.status_code != 200:
|
if user_response.status_code != 200:
|
||||||
logger.error(f"OneDrive token test failed: {user_response.status_code} {user_response.text}")
|
logger.error(f"OneDrive token test failed: {user_response.status_code} {user_response.text}")
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Token validation failed with status {user_response.status_code}: {user_response.text}",
|
"message": f"Token validation failed with status {user_response.status_code}: {user_response.text}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get user info
|
# Get user info
|
||||||
user_info = user_response.json()
|
user_info = user_response.json()
|
||||||
display_name = user_info.get("displayName", "Unknown user")
|
display_name = user_info.get("displayName", "Unknown user")
|
||||||
email = user_info.get("userPrincipalName", "Unknown email")
|
email = user_info.get("userPrincipalName", "Unknown email")
|
||||||
|
|
||||||
|
|||||||
+63
-56
@@ -9,7 +9,8 @@ import urllib.parse
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import aiofiles
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, HttpUrl, field_validator
|
from pydantic import BaseModel, HttpUrl, field_validator
|
||||||
|
|
||||||
@@ -153,67 +154,73 @@ async def process_url(request: Request, url_request: URLUploadRequest):
|
|||||||
logger.info(f"Downloading file from URL: {url}")
|
logger.info(f"Downloading file from URL: {url}")
|
||||||
|
|
||||||
# Use configured timeout to prevent hanging
|
# Use configured timeout to prevent hanging
|
||||||
response = requests.get(
|
async with httpx.AsyncClient(
|
||||||
url,
|
|
||||||
timeout=settings.http_request_timeout,
|
timeout=settings.http_request_timeout,
|
||||||
stream=True, # Stream to handle large files
|
follow_redirects=True,
|
||||||
allow_redirects=True, # Follow redirects
|
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": "DocuElevate/1.0", # Identify ourselves
|
"User-Agent": "DocuElevate/1.0", # Identify ourselves
|
||||||
},
|
},
|
||||||
)
|
) as client:
|
||||||
response.raise_for_status()
|
async with client.stream("GET", url) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
# Validate content type
|
# Validate content type
|
||||||
content_type = response.headers.get("Content-Type", "")
|
content_type = response.headers.get("Content-Type", "")
|
||||||
if not validate_file_type(content_type, safe_filename):
|
if not validate_file_type(content_type, safe_filename):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Unsupported file type: {content_type}. "
|
detail=f"Unsupported file type: {content_type}. "
|
||||||
"Supported types: PDF, Office documents, images, plain text",
|
"Supported types: PDF, Office documents, images, plain text",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check content length before downloading
|
# Check content length before downloading
|
||||||
content_length = response.headers.get("Content-Length")
|
content_length = response.headers.get("Content-Length")
|
||||||
if content_length:
|
if content_length:
|
||||||
file_size = int(content_length)
|
file_size = int(content_length)
|
||||||
max_size = settings.max_upload_size
|
max_size = settings.max_upload_size
|
||||||
if file_size > max_size:
|
if file_size > max_size:
|
||||||
raise HTTPException(
|
|
||||||
status_code=413,
|
|
||||||
detail=f"File too large: {file_size} bytes (max {max_size} bytes)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate unique filename
|
|
||||||
unique_id = str(uuid.uuid4())
|
|
||||||
if "." in safe_filename:
|
|
||||||
file_extension = safe_filename.rsplit(".", 1)[1]
|
|
||||||
target_filename = f"{unique_id}.{file_extension}"
|
|
||||||
else:
|
|
||||||
target_filename = unique_id
|
|
||||||
|
|
||||||
target_path = os.path.join(settings.workdir, target_filename)
|
|
||||||
|
|
||||||
# Download file in chunks to handle large files
|
|
||||||
downloaded_size = 0
|
|
||||||
max_size = settings.max_upload_size
|
|
||||||
|
|
||||||
with open(target_path, "wb") as f:
|
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
|
||||||
if chunk:
|
|
||||||
f.write(chunk)
|
|
||||||
downloaded_size += len(chunk)
|
|
||||||
|
|
||||||
# Check size during download
|
|
||||||
if downloaded_size > max_size:
|
|
||||||
# Remove partial file
|
|
||||||
f.close()
|
|
||||||
os.remove(target_path)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=413,
|
status_code=413,
|
||||||
detail=f"File too large: exceeded {max_size} bytes during download",
|
detail=f"File too large: {file_size} bytes (max {max_size} bytes)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
unique_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Check for extension using original_filename to avoid any CodeQL issues
|
||||||
|
# with safe_filename which is derived from the URL directly.
|
||||||
|
if "." in original_filename:
|
||||||
|
_, ext = os.path.splitext(original_filename)
|
||||||
|
# Strip out the leading dot and any non-alphanumeric chars
|
||||||
|
clean_ext = "".join(c for c in ext if c.isalnum())
|
||||||
|
if not clean_ext:
|
||||||
|
clean_ext = "bin"
|
||||||
|
target_filename = f"{unique_id}.{clean_ext}"
|
||||||
|
else:
|
||||||
|
target_filename = unique_id
|
||||||
|
|
||||||
|
target_path = os.path.join(settings.workdir, target_filename)
|
||||||
|
|
||||||
|
# Download file in chunks to handle large files
|
||||||
|
downloaded_size = 0
|
||||||
|
max_size = settings.max_upload_size
|
||||||
|
|
||||||
|
async with aiofiles.open(target_path, "wb") as f:
|
||||||
|
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||||
|
if chunk:
|
||||||
|
await f.write(chunk)
|
||||||
|
downloaded_size += len(chunk)
|
||||||
|
|
||||||
|
# Check size during download
|
||||||
|
if downloaded_size > max_size:
|
||||||
|
# Remove partial file
|
||||||
|
await f.close()
|
||||||
|
os.remove(target_path)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"File too large: exceeded {max_size} bytes during download",
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Downloaded file from URL '{url}' as '{target_filename}' ({downloaded_size} bytes)")
|
logger.info(f"Downloaded file from URL '{url}' as '{target_filename}' ({downloaded_size} bytes)")
|
||||||
|
|
||||||
# Enqueue for processing
|
# Enqueue for processing
|
||||||
@@ -227,19 +234,19 @@ async def process_url(request: Request, url_request: URLUploadRequest):
|
|||||||
"size": downloaded_size,
|
"size": downloaded_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except httpx.TimeoutException:
|
||||||
logger.error(f"Timeout while downloading file from URL: {url}")
|
logger.error(f"Timeout while downloading file from URL: {url}")
|
||||||
raise HTTPException(status_code=408, detail="Request timeout: server took too long to respond")
|
raise HTTPException(status_code=408, detail="Request timeout: server took too long to respond")
|
||||||
|
|
||||||
except requests.exceptions.ConnectionError as e:
|
except httpx.ConnectError as e:
|
||||||
logger.error(f"Connection error while downloading file from URL: {url} - {str(e)}")
|
logger.error(f"Connection error while downloading file from URL: {url} - {str(e)}")
|
||||||
raise HTTPException(status_code=502, detail=f"Failed to connect to URL: {str(e)}")
|
raise HTTPException(status_code=502, detail=f"Failed to connect to URL: {str(e)}")
|
||||||
|
|
||||||
except requests.exceptions.HTTPError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
logger.error(f"HTTP error while downloading file from URL: {url} - {str(e)}")
|
logger.error(f"HTTP error while downloading file from URL: {url} - {str(e)}")
|
||||||
raise HTTPException(status_code=e.response.status_code, detail=f"HTTP error: {str(e)}")
|
raise HTTPException(status_code=e.response.status_code, detail=f"HTTP error: {str(e)}")
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
except httpx.RequestError as e:
|
||||||
logger.error(f"Error downloading file from URL: {url} - {str(e)}")
|
logger.error(f"Error downloading file from URL: {url} - {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to download file: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to download file: {str(e)}")
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ logger = logging.getLogger(__name__)
|
|||||||
# Constants
|
# Constants
|
||||||
_LOGO_FILENAME = "logo.png"
|
_LOGO_FILENAME = "logo.png"
|
||||||
|
|
||||||
|
# Mapping from PDF metadata keys (with leading slash stripped) to application-specific names.
|
||||||
|
# This mirrors the inverse of the mapping used in app/tasks/embed_metadata_into_pdf.py.
|
||||||
|
_PDF_METADATA_KEY_MAP = {
|
||||||
|
"Title": "filename",
|
||||||
|
"Author": "absender",
|
||||||
|
"Subject": "document_type",
|
||||||
|
"Keywords": "tags",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_email_template(template_name="default.html"):
|
def get_email_template(template_name="default.html"):
|
||||||
"""
|
"""
|
||||||
@@ -64,9 +73,12 @@ def extract_metadata_from_file(file_path):
|
|||||||
"""
|
"""
|
||||||
Try to extract metadata from a file using several methods:
|
Try to extract metadata from a file using several methods:
|
||||||
1. Check for a .json metadata file with the same name
|
1. Check for a .json metadata file with the same name
|
||||||
2. Extract metadata from PDF if it's embedded
|
2. Extract embedded metadata from PDF using pypdf
|
||||||
|
|
||||||
Returns a dictionary of metadata or None if not found
|
JSON metadata takes precedence; embedded PDF metadata fills in any missing
|
||||||
|
fields using the application's standard key mapping (e.g., /Title → filename).
|
||||||
|
|
||||||
|
Returns a dictionary of metadata (may be empty if none found).
|
||||||
"""
|
"""
|
||||||
metadata = {}
|
metadata = {}
|
||||||
|
|
||||||
@@ -77,7 +89,6 @@ def extract_metadata_from_file(file_path):
|
|||||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
logger.info(f"Loaded metadata from external JSON file: {metadata_path}")
|
logger.info(f"Loaded metadata from external JSON file: {metadata_path}")
|
||||||
return metadata
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load metadata from JSON file: {str(e)}")
|
logger.warning(f"Failed to load metadata from JSON file: {str(e)}")
|
||||||
|
|
||||||
@@ -88,11 +99,14 @@ def extract_metadata_from_file(file_path):
|
|||||||
pdf_reader = pypdf.PdfReader(f)
|
pdf_reader = pypdf.PdfReader(f)
|
||||||
pdf_metadata = pdf_reader.metadata
|
pdf_metadata = pdf_reader.metadata
|
||||||
if pdf_metadata:
|
if pdf_metadata:
|
||||||
# Convert metadata to a standard dictionary
|
|
||||||
for key, value in pdf_metadata.items():
|
for key, value in pdf_metadata.items():
|
||||||
# Remove the leading slash from PDF metadata keys (e.g., '/Title' -> 'Title')
|
# Remove the leading slash from PDF metadata keys (e.g., '/Title' -> 'Title')
|
||||||
clean_key = key[1:] if key.startswith("/") else key
|
clean_key = key[1:] if key.startswith("/") else key
|
||||||
metadata[clean_key] = str(value)
|
# Map to application-specific key names where possible
|
||||||
|
mapped_key = _PDF_METADATA_KEY_MAP.get(clean_key, clean_key)
|
||||||
|
# Only set if not already present (JSON metadata takes precedence)
|
||||||
|
if mapped_key not in metadata:
|
||||||
|
metadata[mapped_key] = str(value)
|
||||||
|
|
||||||
logger.info(f"Extracted embedded metadata from PDF: {file_path}")
|
logger.info(f"Extracted embedded metadata from PDF: {file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import httpx
|
||||||
|
from unittest.mock import patch, MagicMock, AsyncMock
|
||||||
|
from app.api.onedrive import test_onedrive_token
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
settings.onedrive_refresh_token = "dummy"
|
||||||
|
settings.onedrive_client_id = "dummy"
|
||||||
|
settings.onedrive_client_secret = "dummy"
|
||||||
|
|
||||||
|
class DummyRequest:
|
||||||
|
def __init__(self):
|
||||||
|
self.session = {"user": "dummy"}
|
||||||
|
|
||||||
|
async def run_benchmark(func_name, mock_post, mock_get):
|
||||||
|
mock_post_resp = MagicMock()
|
||||||
|
mock_post_resp.status_code = 200
|
||||||
|
mock_post_resp.json.return_value = {
|
||||||
|
"access_token": "dummy_access",
|
||||||
|
"expires_in": 3600
|
||||||
|
}
|
||||||
|
mock_post.return_value = mock_post_resp
|
||||||
|
|
||||||
|
mock_get_resp = MagicMock()
|
||||||
|
mock_get_resp.status_code = 200
|
||||||
|
mock_get_resp.json.return_value = {
|
||||||
|
"displayName": "Test User",
|
||||||
|
"userPrincipalName": "test@example.com"
|
||||||
|
}
|
||||||
|
mock_get.return_value = mock_get_resp
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
for _ in range(100):
|
||||||
|
await test_onedrive_token(DummyRequest())
|
||||||
|
end_time = time.time()
|
||||||
|
print(f"{func_name} took {end_time - start_time:.4f} seconds")
|
||||||
|
|
||||||
|
async def run_benchmark_async(func_name, mock_post, mock_get):
|
||||||
|
mock_post_resp = MagicMock()
|
||||||
|
mock_post_resp.status_code = 200
|
||||||
|
mock_post_resp.json = MagicMock(return_value={
|
||||||
|
"access_token": "dummy_access",
|
||||||
|
"expires_in": 3600
|
||||||
|
})
|
||||||
|
mock_post.return_value = mock_post_resp
|
||||||
|
|
||||||
|
mock_get_resp = MagicMock()
|
||||||
|
mock_get_resp.status_code = 200
|
||||||
|
mock_get_resp.json = MagicMock(return_value={
|
||||||
|
"displayName": "Test User",
|
||||||
|
"userPrincipalName": "test@example.com"
|
||||||
|
})
|
||||||
|
mock_get.return_value = mock_get_resp
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
for _ in range(100):
|
||||||
|
await test_onedrive_token(DummyRequest())
|
||||||
|
end_time = time.time()
|
||||||
|
print(f"{func_name} took {end_time - start_time:.4f} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
@patch('app.api.onedrive.requests.get')
|
||||||
|
@patch('app.api.onedrive.requests.post')
|
||||||
|
def benchmark_sync(mock_post, mock_get):
|
||||||
|
asyncio.run(run_benchmark("Sync requests (baseline)", mock_post, mock_get))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
benchmark_sync()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from app.api.url_upload import process_url, URLUploadRequest
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Mock request and URLUploadRequest
|
||||||
|
request = Mock()
|
||||||
|
url_request = URLUploadRequest(url="https://example.com/file.pdf")
|
||||||
|
|
||||||
|
# Generate a large chunk
|
||||||
|
large_chunk = b"A" * 8192
|
||||||
|
num_chunks = 10000 # 8192 * 10000 = ~80MB
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.headers = {"Content-Type": "application/pdf"}
|
||||||
|
mock_response.iter_content = Mock(return_value=[large_chunk] * num_chunks)
|
||||||
|
|
||||||
|
# For async client later
|
||||||
|
class AsyncMockResponse:
|
||||||
|
def __init__(self):
|
||||||
|
self.status_code = 200
|
||||||
|
self.headers = {"Content-Type": "application/pdf"}
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
async def aiter_bytes(self, chunk_size):
|
||||||
|
for _ in range(num_chunks):
|
||||||
|
yield large_chunk
|
||||||
|
|
||||||
|
async_mock_response = AsyncMockResponse()
|
||||||
|
|
||||||
|
# We will mock requests.get for synchronous, httpx.AsyncClient.get for asynchronous
|
||||||
|
|
||||||
|
# Test sync
|
||||||
|
start_time = time.time()
|
||||||
|
with patch("app.api.url_upload.requests.get", return_value=mock_response), \
|
||||||
|
patch("app.api.url_upload.process_document"):
|
||||||
|
try:
|
||||||
|
await process_url(request=request, url_request=url_request)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
end_time = time.time()
|
||||||
|
print(f"Original execution time (sync writing): {end_time - start_time:.4f} seconds")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api.url_upload import process_url, URLUploadRequest
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Setup test dir
|
||||||
|
test_dir = tempfile.mkdtemp()
|
||||||
|
settings.workdir = test_dir
|
||||||
|
|
||||||
|
# Mock request and URLUploadRequest
|
||||||
|
request = Mock()
|
||||||
|
url_request = URLUploadRequest(url="https://example.com/file.pdf")
|
||||||
|
|
||||||
|
# Generate a large chunk
|
||||||
|
chunk_size = 8192
|
||||||
|
num_chunks = 20000 # 20000 * 8192 = ~160MB
|
||||||
|
large_chunk = b"A" * chunk_size
|
||||||
|
|
||||||
|
class SyncMockResponse:
|
||||||
|
def __init__(self):
|
||||||
|
self.status_code = 200
|
||||||
|
self.headers = {"Content-Type": "application/pdf"}
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
def iter_content(self, chunk_size):
|
||||||
|
for _ in range(num_chunks):
|
||||||
|
# sleep slightly to simulate network latency, otherwise OS file cache obscures the difference
|
||||||
|
time.sleep(0.0001)
|
||||||
|
yield large_chunk
|
||||||
|
|
||||||
|
sync_mock_response = SyncMockResponse()
|
||||||
|
|
||||||
|
class AsyncMockResponse:
|
||||||
|
def __init__(self):
|
||||||
|
self.status_code = 200
|
||||||
|
self.headers = {"Content-Type": "application/pdf"}
|
||||||
|
self.is_success = True
|
||||||
|
self.status_code = 200
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
async def aiter_bytes(self, chunk_size=8192):
|
||||||
|
for _ in range(num_chunks):
|
||||||
|
await asyncio.sleep(0.0001)
|
||||||
|
yield large_chunk
|
||||||
|
|
||||||
|
class AsyncMockContext:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return AsyncMockResponse()
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async_mock_response = AsyncMockResponse()
|
||||||
|
|
||||||
|
# Test sync
|
||||||
|
start_time = time.time()
|
||||||
|
with patch("app.api.url_upload.requests.get", return_value=sync_mock_response), \
|
||||||
|
patch("app.api.url_upload.process_document"):
|
||||||
|
try:
|
||||||
|
await process_url(request=request, url_request=url_request)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error (sync): {e}")
|
||||||
|
end_time = time.time()
|
||||||
|
print(f"Original execution time (sync writing): {end_time - start_time:.4f} seconds")
|
||||||
|
|
||||||
|
shutil.rmtree(test_dir)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open("tests/test_api_saved_searches.py", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# We need to mock get_current_user in app.api.saved_searches (which is imported from app.auth)
|
||||||
|
# because saved searches uses `_get_user_id` which calls `get_current_user(request)`.
|
||||||
|
# But `_get_user_id` is NOT a dependency injected via `Depends`!
|
||||||
|
# Let's verify `app/api/saved_searches.py` uses `Depends` or just calls it.
|
||||||
|
|
||||||
|
# In `app/api/saved_searches.py`:
|
||||||
|
# def _get_user_id(request: Request) -> str:
|
||||||
|
# user = get_current_user(request)
|
||||||
|
# if user:
|
||||||
|
# return user.get("preferred_username") ...
|
||||||
|
# It's called directly inside the routes: `user_id = _get_user_id(request)`
|
||||||
|
# It doesn't use `Depends(_get_user_id)`.
|
||||||
|
# Ah! But earlier I saw `_get_user_id` wasn't mocked properly. Let's use patch to mock `_get_user_id`.
|
||||||
|
|
||||||
|
# Wait, `TestClient` can be given an active session, but `app.auth.get_current_user` uses `request.session.get("user")` or Bearer token.
|
||||||
|
# Is `AUTH_ENABLED` false? The test env has `os.environ["AUTH_ENABLED"] = "False"` in `tests/conftest.py`.
|
||||||
|
# If `AUTH_ENABLED` is false, `require_login` is a no-op, and `_get_user_id` falls back to "anonymous".
|
||||||
|
# Actually, `_get_user_id` returns "anonymous" if `get_current_user(request)` is None.
|
||||||
|
# If `_OWNER` is "test_user@example.com", we should probably just patch `_get_user_id`.
|
||||||
|
|
||||||
|
replacement = """def _make_client(int_engine, owner_id: str = _OWNER):
|
||||||
|
\"\"\"Return a TestClient with *owner_id* injected as the authenticated user.\"\"\"
|
||||||
|
from app.main import app
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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()"""
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r"def _make_client\(int_engine, owner_id: str = _OWNER\):.*?(?=@pytest\.fixture\(\)\ndef int_client\(int_engine\):)",
|
||||||
|
replacement + "\n\n\n",
|
||||||
|
content,
|
||||||
|
flags=re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
with open("tests/test_api_saved_searches.py", "w") as f:
|
||||||
|
f.write(content)
|
||||||
@@ -38,4 +38,5 @@ pip-licenses==5.5.1 # For license compliance checking
|
|||||||
|
|
||||||
# Release automation
|
# Release automation
|
||||||
python-semantic-release>=9.0.0
|
python-semantic-release>=9.0.0
|
||||||
|
|
||||||
types-aiofiles>=24.1.0.20240311 # Type stubs for aiofiles
|
types-aiofiles>=24.1.0.20240311 # Type stubs for aiofiles
|
||||||
|
|||||||
+4
-3
@@ -50,12 +50,13 @@ litellm>=1.0.0,<2.0.0
|
|||||||
pytesseract>=0.3.10 # Python wrapper for Tesseract OCR
|
pytesseract>=0.3.10 # Python wrapper for Tesseract OCR
|
||||||
pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers)
|
pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers)
|
||||||
ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract
|
ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract
|
||||||
meilisearch>=0.31.0 # Full-text search engine client
|
meilisearch>=0.31.0 # Full-text search engine client
|
||||||
stripe>=7.0.0,<15.0.0 # Stripe billing SDK (MIT license)
|
stripe>=7.0.0,<15.0.0 # Stripe billing SDK (MIT license)
|
||||||
|
|
||||||
# Error and performance monitoring
|
# Error and performance monitoring
|
||||||
sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
|
sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
|
||||||
|
|
||||||
# GraphQL API
|
# GraphQL API
|
||||||
strawberry-graphql[fastapi]>=0.243.0,<1.0.0
|
strawberry-graphql[fastapi]>=0.243.0,<1.0.0
|
||||||
aiofiles>=24.1.0 # Asynchronous file I/O support
|
|
||||||
|
aiofiles>=24.1.0 # Asynchronous file I/O support
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Focuses on uncovered lines: 98-99, 121-143, 160-161, 170-171,
|
|||||||
324-326, 400-402, 436-438.
|
324-326, 400-402, 436-438.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest.mock import MagicMock, PropertyMock, patch
|
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -15,7 +15,7 @@ from fastapi.testclient import TestClient
|
|||||||
class TestTestTokenRefreshFailed:
|
class TestTestTokenRefreshFailed:
|
||||||
"""Cover lines 98-99: token refresh returns non-200."""
|
"""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):
|
def test_test_token_refresh_returns_non_200(self, mock_post, client: TestClient):
|
||||||
"""Test token refresh returning a failure status hits the error branch."""
|
"""Test token refresh returning a failure status hits the error branch."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -42,8 +42,8 @@ class TestTestTokenRefreshFailed:
|
|||||||
class TestTestTokenRotation:
|
class TestTestTokenRotation:
|
||||||
"""Cover lines 121-143, 160-161: token rotation with .env and DB persist."""
|
"""Cover lines 121-143, 160-161: token rotation with .env and DB persist."""
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.get")
|
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
def test_token_rotation_env_file_exists(self, mock_post, mock_get, client: TestClient, tmp_path):
|
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."""
|
"""When a new refresh token is received and .env file exists, it should be updated."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -90,8 +90,8 @@ class TestTestTokenRotation:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["status"] == "success"
|
assert data["status"] == "success"
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.get")
|
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
def test_token_rotation_env_not_existing(self, mock_post, mock_get, client: TestClient):
|
def test_token_rotation_env_not_existing(self, mock_post, mock_get, client: TestClient):
|
||||||
"""Token rotation when .env doesn't exist still succeeds."""
|
"""Token rotation when .env doesn't exist still succeeds."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -130,8 +130,8 @@ class TestTestTokenRotation:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "success"
|
assert response.json()["status"] == "success"
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.get")
|
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
def test_token_rotation_env_write_failure(self, mock_post, mock_get, client: TestClient):
|
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."""
|
"""Token rotation when .env write fails (lines 142-143) still continues."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -171,8 +171,8 @@ class TestTestTokenRotation:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "success"
|
assert response.json()["status"] == "success"
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.get")
|
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
def test_token_rotation_db_persist_failure(self, mock_post, mock_get, client: TestClient):
|
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."""
|
"""Token rotation when DB persist fails (lines 160-161) still continues."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -211,8 +211,8 @@ class TestTestTokenRotation:
|
|||||||
class TestTestTokenUserInfoFailed:
|
class TestTestTokenUserInfoFailed:
|
||||||
"""Cover lines 170-171: user info request fails."""
|
"""Cover lines 170-171: user info request fails."""
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.get")
|
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
def test_user_info_returns_non_200(self, mock_post, mock_get, client: TestClient):
|
def test_user_info_returns_non_200(self, mock_post, mock_get, client: TestClient):
|
||||||
"""Test when user info request fails after successful token refresh."""
|
"""Test when user info request fails after successful token refresh."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -247,8 +247,8 @@ class TestTestTokenUserInfoFailed:
|
|||||||
class TestTokenRotationEnvAppendLine:
|
class TestTokenRotationEnvAppendLine:
|
||||||
"""Cover the branch at line 134 where token line is not found in .env and must be appended."""
|
"""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("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
def test_token_rotation_appends_to_env(self, mock_post, mock_get, client: TestClient, tmp_path):
|
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."""
|
"""When .env exists but doesn't have ONEDRIVE_REFRESH_TOKEN, it should append."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Comprehensive unit tests for app/api/onedrive.py module."""
|
"""Comprehensive unit tests for app/api/onedrive.py module."""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -48,8 +48,8 @@ class TestExchangeOneDriveToken:
|
|||||||
class TestTestOneDriveToken:
|
class TestTestOneDriveToken:
|
||||||
"""Tests for GET /onedrive/test-token endpoint."""
|
"""Tests for GET /onedrive/test-token endpoint."""
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
@patch("app.api.onedrive.requests.get")
|
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||||
def test_test_token_success(self, mock_get, mock_post):
|
def test_test_token_success(self, mock_get, mock_post):
|
||||||
"""Test successful token validation."""
|
"""Test successful token validation."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -79,7 +79,7 @@ class TestTestOneDriveToken:
|
|||||||
# Should return success
|
# Should return success
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
def test_test_token_not_configured(self, mock_post):
|
def test_test_token_not_configured(self, mock_post):
|
||||||
"""Test when credentials are not configured."""
|
"""Test when credentials are not configured."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -88,7 +88,7 @@ class TestTestOneDriveToken:
|
|||||||
# Should return error
|
# Should return error
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
def test_test_token_refresh_failed(self, mock_post):
|
def test_test_token_refresh_failed(self, mock_post):
|
||||||
"""Test when token refresh fails."""
|
"""Test when token refresh fails."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -104,8 +104,8 @@ class TestTestOneDriveToken:
|
|||||||
# Should return error with needs_reauth
|
# Should return error with needs_reauth
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
@patch("app.api.onedrive.requests.get")
|
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||||
def test_test_token_user_info_failed(self, mock_get, mock_post):
|
def test_test_token_user_info_failed(self, mock_get, mock_post):
|
||||||
"""Test when user info request fails."""
|
"""Test when user info request fails."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -128,8 +128,8 @@ class TestTestOneDriveToken:
|
|||||||
# Should return error
|
# Should return error
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
@patch("app.api.onedrive.requests.get")
|
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||||
@patch("builtins.open", create=True)
|
@patch("builtins.open", create=True)
|
||||||
@patch("os.path.exists")
|
@patch("os.path.exists")
|
||||||
def test_test_token_updates_refresh_token(self, mock_exists, mock_open, mock_get, mock_post):
|
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
|
# Should update refresh token in memory and file
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
@patch("app.api.onedrive.requests.get")
|
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||||
def test_test_token_expiration_info(self, mock_get, mock_post):
|
def test_test_token_expiration_info(self, mock_get, mock_post):
|
||||||
"""Test that expiration info is included."""
|
"""Test that expiration info is included."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -195,7 +195,7 @@ class TestTestOneDriveToken:
|
|||||||
# token_info should include expiration details
|
# token_info should include expiration details
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@patch("app.api.onedrive.requests.post")
|
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||||
def test_test_token_exception_handling(self, mock_post):
|
def test_test_token_exception_handling(self, mock_post):
|
||||||
"""Test handling of exceptions."""
|
"""Test handling of exceptions."""
|
||||||
from app.config import settings
|
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:
|
def test_onedrive_token_refresh_and_user_info(self, original_env: dict) -> None:
|
||||||
"""Validate token refresh and user info retrieval."""
|
"""Validate token refresh and user info retrieval."""
|
||||||
import requests
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
token = self._get_access_token(original_env)
|
token = self._get_access_token(original_env)
|
||||||
resp = requests.get(
|
|
||||||
"https://graph.microsoft.com/v1.0/me",
|
async def _test():
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
timeout=30,
|
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}"
|
assert resp.status_code == 200, f"OneDrive user info failed: {resp.text}"
|
||||||
|
|
||||||
def test_onedrive_upload_download_delete(self, original_env: dict) -> None:
|
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))
|
result = extract_metadata_from_file(str(file_path))
|
||||||
|
|
||||||
# Check that the leading slash is stripped and keys/values match
|
# Keys are mapped to application-specific names
|
||||||
assert result.get("Title") == "Test Title"
|
assert result.get("filename") == "Test Title"
|
||||||
assert result.get("Author") == "Test Author"
|
assert result.get("absender") == "Test Author"
|
||||||
assert result.get("Subject") == "Test Document"
|
assert result.get("document_type") == "Test Document"
|
||||||
assert result.get("Keywords") == "test, metadata, pypdf"
|
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
|
@pytest.mark.unit
|
||||||
|
|||||||
+208
-105
@@ -2,10 +2,10 @@
|
|||||||
Tests for URL-based file upload functionality
|
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 pytest
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -165,17 +165,24 @@ class TestURLUploadValidation:
|
|||||||
class TestURLUploadEndpoint:
|
class TestURLUploadEndpoint:
|
||||||
"""Integration tests for URL upload endpoint"""
|
"""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")
|
@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"""
|
"""Test that endpoint requires authentication when auth is enabled"""
|
||||||
# Mock successful download to prevent actual HTTP requests
|
# Mock successful download to prevent actual HTTP requests
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
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_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 Celery task
|
||||||
mock_task = Mock()
|
mock_task = Mock()
|
||||||
@@ -192,17 +199,24 @@ class TestURLUploadEndpoint:
|
|||||||
# (like no mocking). We're just checking the endpoint exists and is reachable.
|
# (like no mocking). We're just checking the endpoint exists and is reachable.
|
||||||
assert response.status_code != 404 # Endpoint should exist
|
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")
|
@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"""
|
"""Test successful URL processing"""
|
||||||
# Mock successful download
|
# Mock successful download
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
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_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 Celery task
|
||||||
mock_task = Mock()
|
mock_task = Mock()
|
||||||
@@ -219,8 +233,8 @@ class TestURLUploadEndpoint:
|
|||||||
assert "filename" in data
|
assert "filename" in data
|
||||||
assert "size" in data
|
assert "size" in data
|
||||||
|
|
||||||
@patch("app.api.url_upload.requests.get")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_blocks_private_ip(self, mock_requests_get, client):
|
def test_process_url_blocks_private_ip(self, mock_stream, client):
|
||||||
"""Test that private IPs are blocked"""
|
"""Test that private IPs are blocked"""
|
||||||
response = client.post("/api/process-url", json={"url": "http://192.168.1.1/file.pdf"})
|
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"]
|
assert "private/internal" in data["detail"]
|
||||||
|
|
||||||
# Should not make HTTP request
|
# Should not make HTTP request
|
||||||
mock_requests_get.assert_not_called()
|
mock_stream.assert_not_called()
|
||||||
|
|
||||||
@patch("app.api.url_upload.requests.get")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_blocks_localhost(self, mock_requests_get, client):
|
def test_process_url_blocks_localhost(self, mock_stream, client):
|
||||||
"""Test that localhost is blocked"""
|
"""Test that localhost is blocked"""
|
||||||
response = client.post("/api/process-url", json={"url": "http://localhost/file.pdf"})
|
response = client.post("/api/process-url", json={"url": "http://localhost/file.pdf"})
|
||||||
|
|
||||||
@@ -241,10 +255,10 @@ class TestURLUploadEndpoint:
|
|||||||
assert "private/internal" in data["detail"]
|
assert "private/internal" in data["detail"]
|
||||||
|
|
||||||
# Should not make HTTP request
|
# Should not make HTTP request
|
||||||
mock_requests_get.assert_not_called()
|
mock_stream.assert_not_called()
|
||||||
|
|
||||||
@patch("app.api.url_upload.requests.get")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_blocks_metadata_endpoint(self, mock_requests_get, client):
|
def test_process_url_blocks_metadata_endpoint(self, mock_stream, client):
|
||||||
"""Test that cloud metadata endpoints are blocked"""
|
"""Test that cloud metadata endpoints are blocked"""
|
||||||
response = client.post("/api/process-url", json={"url": "http://169.254.169.254/latest/meta-data/"})
|
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"]
|
assert "metadata" in data["detail"] or "private" in data["detail"]
|
||||||
|
|
||||||
# Should not make HTTP request
|
# Should not make HTTP request
|
||||||
mock_requests_get.assert_not_called()
|
mock_stream.assert_not_called()
|
||||||
|
|
||||||
@patch("app.api.url_upload.requests.get")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_invalid_file_type(self, mock_requests_get, client):
|
def test_process_url_invalid_file_type(self, mock_stream, client):
|
||||||
"""Test that invalid file types are rejected"""
|
"""Test that invalid file types are rejected"""
|
||||||
# Mock response with executable content-type
|
# Mock response with executable content-type
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/x-executable"}
|
mock_response.headers = {"Content-Type": "application/x-executable"}
|
||||||
mock_response.raise_for_status = Mock()
|
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"})
|
response = client.post("/api/process-url", json={"url": "https://example.com/malware.exe"})
|
||||||
|
|
||||||
@@ -272,21 +289,24 @@ class TestURLUploadEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "Unsupported file type" in data["detail"]
|
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")
|
@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"""
|
"""Test that files too large are rejected based on Content-Length header"""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# Mock response with large content-length
|
# Mock response with large content-length
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {
|
mock_response.headers = {
|
||||||
"Content-Type": "application/pdf",
|
"Content-Type": "application/pdf",
|
||||||
"Content-Length": str(settings.max_upload_size + 1000),
|
"Content-Length": str(settings.max_upload_size + 1000),
|
||||||
}
|
}
|
||||||
mock_response.raise_for_status = Mock()
|
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"})
|
response = client.post("/api/process-url", json={"url": "https://example.com/huge.pdf"})
|
||||||
|
|
||||||
@@ -297,10 +317,10 @@ class TestURLUploadEndpoint:
|
|||||||
# Should not process document
|
# Should not process document
|
||||||
mock_process_document.delay.assert_not_called()
|
mock_process_document.delay.assert_not_called()
|
||||||
|
|
||||||
@patch("app.api.url_upload.requests.get")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_timeout_error(self, mock_requests_get, client):
|
def test_process_url_timeout_error(self, mock_stream, client):
|
||||||
"""Test handling of timeout errors"""
|
"""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"})
|
response = client.post("/api/process-url", json={"url": "https://example.com/slow.pdf"})
|
||||||
|
|
||||||
@@ -308,10 +328,10 @@ class TestURLUploadEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "timeout" in data["detail"].lower()
|
assert "timeout" in data["detail"].lower()
|
||||||
|
|
||||||
@patch("app.api.url_upload.requests.get")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_connection_error(self, mock_requests_get, client):
|
def test_process_url_connection_error(self, mock_stream, client):
|
||||||
"""Test handling of connection errors"""
|
"""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"})
|
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
||||||
|
|
||||||
@@ -319,15 +339,16 @@ class TestURLUploadEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "connect" in data["detail"].lower()
|
assert "connect" in data["detail"].lower()
|
||||||
|
|
||||||
@patch("app.api.url_upload.requests.get")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_http_error_404(self, mock_requests_get, client):
|
def test_process_url_http_error_404(self, mock_stream, client):
|
||||||
"""Test handling of HTTP 404 errors"""
|
"""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.status_code = 404
|
||||||
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
|
||||||
"404 Not Found", response=mock_response
|
mock_stream.side_effect = httpx.HTTPStatusError("404 Not Found", request=mock_request, response=mock_response)
|
||||||
)
|
|
||||||
mock_requests_get.return_value = mock_response
|
|
||||||
|
|
||||||
response = client.post("/api/process-url", json={"url": "https://example.com/notfound.pdf"})
|
response = client.post("/api/process-url", json={"url": "https://example.com/notfound.pdf"})
|
||||||
|
|
||||||
@@ -335,17 +356,24 @@ class TestURLUploadEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "HTTP error" in data["detail"]
|
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")
|
@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"""
|
"""Test URL upload with custom filename"""
|
||||||
# Mock successful download
|
# Mock successful download
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
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_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 Celery task
|
||||||
mock_task = Mock()
|
mock_task = Mock()
|
||||||
@@ -361,17 +389,24 @@ class TestURLUploadEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["filename"] == "my-document.pdf"
|
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")
|
@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"""
|
"""Test that filename is extracted from URL when not provided"""
|
||||||
# Mock successful download
|
# Mock successful download
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
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_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 Celery task
|
||||||
mock_task = Mock()
|
mock_task = Mock()
|
||||||
@@ -386,9 +421,9 @@ class TestURLUploadEndpoint:
|
|||||||
# Should extract "annual-report.pdf" from URL
|
# Should extract "annual-report.pdf" from URL
|
||||||
assert "annual-report" in data["filename"]
|
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")
|
@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"""
|
"""Test that file size is checked during download"""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@@ -396,12 +431,19 @@ class TestURLUploadEndpoint:
|
|||||||
large_chunk = b"x" * (settings.max_upload_size + 1000)
|
large_chunk = b"x" * (settings.max_upload_size + 1000)
|
||||||
|
|
||||||
# Mock response without Content-Length header
|
# Mock response without Content-Length header
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf"} # No Content-Length
|
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_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"})
|
response = client.post("/api/process-url", json={"url": "https://example.com/big.pdf"})
|
||||||
|
|
||||||
@@ -412,10 +454,10 @@ class TestURLUploadEndpoint:
|
|||||||
# Should not process document
|
# Should not process document
|
||||||
mock_process_document.delay.assert_not_called()
|
mock_process_document.delay.assert_not_called()
|
||||||
|
|
||||||
@patch("app.api.url_upload.requests.get")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_request_exception(self, mock_requests_get, client):
|
def test_process_url_request_exception(self, mock_stream, client):
|
||||||
"""Test handling of generic RequestException"""
|
"""Test handling of generic RequestError"""
|
||||||
mock_requests_get.side_effect = requests.exceptions.RequestException("Generic request error")
|
mock_stream.side_effect = httpx.RequestError("Generic request error")
|
||||||
|
|
||||||
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
||||||
|
|
||||||
@@ -423,16 +465,23 @@ class TestURLUploadEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "Failed to download file" in data["detail"]
|
assert "Failed to download file" in data["detail"]
|
||||||
|
|
||||||
@patch("app.api.url_upload.requests.get")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_oserror_during_save(self, mock_requests_get, client, tmp_path, monkeypatch):
|
def test_process_url_oserror_during_save(self, mock_stream, client, tmp_path, monkeypatch):
|
||||||
"""Test handling of OSError when saving file"""
|
"""Test handling of OSError when saving file"""
|
||||||
# Mock successful download
|
# Mock successful download
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
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_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
|
# Mock workdir to a non-existent path to trigger OSError
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -450,17 +499,24 @@ class TestURLUploadEndpoint:
|
|||||||
# Restore original workdir
|
# Restore original workdir
|
||||||
monkeypatch.setattr(settings, "workdir", 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")
|
@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"""
|
"""Test handling of unexpected exceptions"""
|
||||||
# Mock successful download but process_document.delay raises unexpected error
|
# Mock successful download but process_document.delay raises unexpected error
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
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_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 to raise an unexpected exception
|
||||||
mock_process_document.delay.side_effect = RuntimeError("Unexpected processing error")
|
mock_process_document.delay.side_effect = RuntimeError("Unexpected processing error")
|
||||||
@@ -471,17 +527,24 @@ class TestURLUploadEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "Unexpected error" in data["detail"]
|
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")
|
@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"""
|
"""Test that files without extensions are handled correctly"""
|
||||||
# Mock successful download
|
# Mock successful download
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
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_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 Celery task
|
||||||
mock_task = Mock()
|
mock_task = Mock()
|
||||||
@@ -496,17 +559,24 @@ class TestURLUploadEndpoint:
|
|||||||
# Should still work, just without extension
|
# Should still work, just without extension
|
||||||
assert data["task_id"] == "test-task-id"
|
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")
|
@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"""
|
"""Test that empty URL path defaults to 'download' filename"""
|
||||||
# Mock successful download
|
# Mock successful download
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
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_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 Celery task
|
||||||
mock_task = Mock()
|
mock_task = Mock()
|
||||||
@@ -560,17 +630,24 @@ class TestURLUploadEndpoint:
|
|||||||
# Link-local address
|
# Link-local address
|
||||||
assert is_private_ip("169.254.1.1") is True
|
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")
|
@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"""
|
"""Test that dangerous filenames are sanitized"""
|
||||||
# Mock successful download
|
# Mock successful download
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
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_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 Celery task
|
||||||
mock_task = Mock()
|
mock_task = Mock()
|
||||||
@@ -671,18 +748,25 @@ class TestURLUploadCoverageGaps:
|
|||||||
assert validate_file_type("", "filename_without_extension") is False
|
assert validate_file_type("", "filename_without_extension") is False
|
||||||
|
|
||||||
@patch("app.api.url_upload.sanitize_filename", return_value="")
|
@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")
|
@patch("app.api.url_upload.process_document")
|
||||||
def test_process_url_sanitize_filename_returns_empty(
|
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)"""
|
"""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.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
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_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 = Mock()
|
||||||
mock_task.id = "test-task-id-sanitize"
|
mock_task.id = "test-task-id-sanitize"
|
||||||
@@ -695,17 +779,26 @@ class TestURLUploadCoverageGaps:
|
|||||||
# When sanitize_filename returns "", safe_filename defaults to "download"
|
# When sanitize_filename returns "", safe_filename defaults to "download"
|
||||||
assert data["filename"] == "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")
|
@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)"""
|
"""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.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf"}
|
mock_response.headers = {"Content-Type": "application/pdf"}
|
||||||
# Mix empty bytes (falsy) with real content - covers the `if chunk:` False branch
|
# 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_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 = Mock()
|
||||||
mock_task.id = "test-task-id-chunks"
|
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.remove")
|
||||||
@patch("app.api.url_upload.os.path.exists", return_value=True)
|
@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(
|
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)"""
|
"""Test OSError handler removes the partial file when it exists (line 285)"""
|
||||||
import os
|
import os
|
||||||
@@ -735,12 +828,19 @@ class TestURLUploadCoverageGaps:
|
|||||||
|
|
||||||
monkeypatch.setattr(settings, "workdir", str(non_existent))
|
monkeypatch.setattr(settings, "workdir", str(non_existent))
|
||||||
|
|
||||||
mock_response = Mock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
|
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_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"})
|
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
||||||
|
|
||||||
@@ -750,14 +850,17 @@ class TestURLUploadCoverageGaps:
|
|||||||
mock_remove.assert_called_once()
|
mock_remove.assert_called_once()
|
||||||
|
|
||||||
@patch("app.api.url_upload.validate_file_type", side_effect=ValueError("unexpected internal error"))
|
@patch("app.api.url_upload.validate_file_type", side_effect=ValueError("unexpected internal error"))
|
||||||
@patch("app.api.url_upload.requests.get")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_unexpected_exception_with_no_file_created(self, mock_requests_get, mock_validate, client):
|
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)"""
|
"""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.status_code = 200
|
||||||
mock_response.headers = {"Content-Type": "application/pdf"}
|
mock_response.headers = {"Content-Type": "application/pdf"}
|
||||||
mock_response.raise_for_status = Mock()
|
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"})
|
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user