refactor: consolidate linting tools into Ruff

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-13 09:10:52 +00:00
parent 071c62c03f
commit 43bc58770d
98 changed files with 739 additions and 1168 deletions
+9 -82
View File
@@ -73,9 +73,9 @@ jobs:
junit.xml
coverage.xml
# ── Flake8 ─────────────────────────────────────────────────────────────
flake8:
name: Flake8
# ── Lint (Ruff) ───────────────────────────────────────────────────────
lint:
name: Ruff Lint & Format
runs-on: ubuntu-latest
steps:
- name: Checkout Code
@@ -86,34 +86,14 @@ jobs:
with:
python-version: "3.11"
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install flake8
- name: Install Ruff
run: pip install ruff
- name: Run Flake8
run: flake8 app/ --max-line-length=120 --extend-ignore=E203,W503
- name: Run Ruff Check
run: ruff check app/ tests/
# ── Black ──────────────────────────────────────────────────────────────
black:
name: Black
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install black
- name: Run Black
run: black --check app/ --line-length=120
- name: Run Ruff Format
run: ruff format --check app/ tests/
# ── Mypy ───────────────────────────────────────────────────────────────
mypy:
@@ -135,56 +115,3 @@ jobs:
- name: Run Mypy
run: mypy app/
# ── Pylint ─────────────────────────────────────────────────────────────
pylint:
name: Pylint
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run Pylint
run: pylint app/
# ── Bandit ─────────────────────────────────────────────────────────────
bandit:
name: Bandit
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install bandit
- name: Run Bandit - Full Report
if: ${{ !cancelled() }}
run: bandit -r app/ -f json -o bandit-report.json || true
- name: Run Bandit - Fail on High/Medium
run: bandit -r app/ -ll
- name: Upload Bandit Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: bandit-report
path: bandit-report.json
+6 -28
View File
@@ -19,35 +19,13 @@ repos:
- id: detect-aws-credentials
args: ['--allow-missing-credentials']
# Python code formatting
- repo: https://github.com/psf/black
rev: 24.1.1
# Ruff - Fast Python linter and formatter (replaces Black, Flake8, isort, Bandit)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
hooks:
- id: black
args: ['--line-length=120']
language_version: python3.11
# Import sorting
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
- id: isort
args: ['--profile=black', '--line-length=120']
# Linting
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
hooks:
- id: flake8
args: ['--max-line-length=120', '--extend-ignore=E203,W503']
# Security linting
- repo: https://github.com/PyCQA/bandit
rev: 1.7.6
hooks:
- id: bandit
args: ['-ll', '-r', 'app/']
exclude: 'tests/'
- id: ruff
args: [ --fix ]
- id: ruff-format
# Type checking
- repo: https://github.com/pre-commit/mirrors-mypy
+2 -2
View File
@@ -374,7 +374,7 @@ def reprocess_single_file(request: Request, file_id: int, db: DbSession):
)
logger.info(
f"Reprocessing file: ID={file_record.id}, " f"Filename={file_record.original_filename}, TaskID={task.id}"
f"Reprocessing file: ID={file_record.id}, Filename={file_record.original_filename}, TaskID={task.id}"
)
return {
@@ -645,7 +645,7 @@ def retry_subtask(
upload_task = task_map[subtask_name]
task = upload_task.delay(file_path, file_id)
logger.info(f"Retrying upload subtask: FileID={file_record.id}, " f"Subtask={subtask_name}, TaskID={task.id}")
logger.info(f"Retrying upload subtask: FileID={file_record.id}, Subtask={subtask_name}, TaskID={task.id}")
return {
"status": "success",
+1 -1
View File
@@ -146,7 +146,7 @@ def validate_file_type(content_type: str, filename: str) -> bool:
# Check content type from header
if content_type:
# Handle content-type with charset (e.g., "application/pdf; charset=utf-8")
base_content_type = content_type.split(";")[0].strip().lower()
base_content_type = content_type.split(";", maxsplit=1)[0].strip().lower()
if base_content_type in ALLOWED_MIME_TYPES or base_content_type in IMAGE_MIME_TYPES:
return True
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
import os
from typing import Any, List, Optional, Union
from typing import List, Optional, Union
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
+1 -2
View File
@@ -5,8 +5,7 @@ import os
from sqlalchemy import create_engine, exc
from sqlalchemy.engine.url import make_url
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import declarative_base, sessionmaker
from app.config import settings
+1 -3
View File
@@ -340,9 +340,7 @@ def convert_to_pdf(self, file_path: str, original_filename: Optional[str] = None
else:
error_msg = f"Status code: {response.status_code}"
logger.error(
f"[{task_id}] Conversion failed for {file_path}. "
f"{error_msg}, "
f"Response: {response.text[:500]}..."
f"[{task_id}] Conversion failed for {file_path}. {error_msg}, Response: {response.text[:500]}..."
)
log_task_progress(task_id, "call_gotenberg", "failure", error_msg)
log_task_progress(task_id, "convert_to_pdf", "failure", f"Conversion failed: {error_msg}")
+1 -2
View File
@@ -256,8 +256,7 @@ def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, met
f"Exception: {str(e)}",
file_id=file_id,
detail=(
f"Failed to embed metadata into {processed_file}.\n"
f"Original file: {original_file}\nException: {str(e)}"
f"Failed to embed metadata into {processed_file}.\nOriginal file: {original_file}\nException: {str(e)}"
),
)
# Clean up temporary file in case of error
+1 -1
View File
@@ -42,7 +42,7 @@ def monitor_stalled_steps():
f"Marked as failed due to timeout."
)
else:
logger.debug(f"[{datetime.utcnow().isoformat()}] " f"No stalled steps found.")
logger.debug(f"[{datetime.utcnow().isoformat()}] No stalled steps found.")
return {"recovered": stalled_count}
@@ -70,13 +70,13 @@ def check_page_rotation(result, filename, task_id=None):
if hasattr(page, "angle"):
rotation_angle = page.angle
if rotation_angle != 0:
logger.info(f"{prefix}Page {i+1} is rotated by {rotation_angle} degrees")
logger.info(f"{prefix}Page {i + 1} is rotated by {rotation_angle} degrees")
# Store page index as integer, not string
rotation_data[i] = rotation_angle
else:
logger.info(f"{prefix}Page {i+1} has no rotation (0 degrees)")
logger.info(f"{prefix}Page {i + 1} has no rotation (0 degrees)")
else:
logger.info(f"{prefix}Page {i+1} rotation information not available")
logger.info(f"{prefix}Page {i + 1} rotation information not available")
return rotation_data
+3 -3
View File
@@ -136,13 +136,13 @@ def rotate_pdf_pages(self, filename: str, extracted_text: str, rotation_data=Non
# pypdf uses clockwise rotation in 90-degree increments
page.rotate(rotation_angle)
logger.info(
f"[{task_id}] Page {page_idx+1} rotated by {rotation_angle}° "
f"[{task_id}] Page {page_idx + 1} rotated by {rotation_angle}° "
f"(from detected {detected_angle}°)"
)
applied_rotations[str(page_idx)] = rotation_angle
else:
logger.info(
f"[{task_id}] Page {page_idx+1} had detected angle {detected_angle}° "
f"[{task_id}] Page {page_idx + 1} had detected angle {detected_angle}° "
"but determined it doesn't need rotation"
)
@@ -154,7 +154,7 @@ def rotate_pdf_pages(self, filename: str, extracted_text: str, rotation_data=Non
if applied_rotations:
logger.info(
f"[{task_id}] Successfully rotated PDF: {filename} with rotations: " f"{json.dumps(applied_rotations)}"
f"[{task_id}] Successfully rotated PDF: {filename} with rotations: {json.dumps(applied_rotations)}"
)
else:
logger.info(
+2 -2
View File
@@ -188,11 +188,11 @@ def upload_large_file(file_path, upload_url):
# 201 = Created (final chunk), 202 = Accepted (more chunks coming)
break
else:
logger.warning(f"Chunk upload failed (attempt {attempt+1}): {response.status_code}")
logger.warning(f"Chunk upload failed (attempt {attempt + 1}): {response.status_code}")
if attempt < max_retries - 1:
time.sleep(retry_delay * (attempt + 1))
except Exception as e:
logger.warning(f"Chunk upload error (attempt {attempt+1}): {str(e)}")
logger.warning(f"Chunk upload error (attempt {attempt + 1}): {str(e)}")
if attempt < max_retries - 1:
time.sleep(retry_delay * (attempt + 1))
@@ -192,9 +192,7 @@ def get_settings_for_display(show_values=False):
[
key
for key in dir(settings)
if not key.startswith("_")
and key not in _PYDANTIC_INTERNALS
and not callable(getattr(settings, key))
if not key.startswith("_") and key not in _PYDANTIC_INTERNALS and not callable(getattr(settings, key))
]
)
-1
View File
@@ -97,7 +97,6 @@ def split_pdf_by_size(pdf_path: str, max_size_bytes: int, output_dir: Optional[s
# If adding this page exceeds the limit (and we have more than 1 page in current chunk)
# save the previous chunk and start a new one
if exceeds_limit and current_page_count > 1:
# Create a new writer without the last page
previous_writer = PdfWriter()
for prev_page_num in range(page_num - current_page_count + 1, page_num):
+1 -1
View File
@@ -310,7 +310,7 @@ def verify_migration(db: Session, file_id: int) -> Dict:
if expected["status"] != actual.status:
result["discrepancies"].append(
f"Step '{step_name}' status mismatch: " f"expected '{expected['status']}', got '{actual.status}'"
f"Step '{step_name}' status mismatch: expected '{expected['status']}', got '{actual.status}'"
)
result["is_valid"] = False
+1 -1
View File
@@ -73,7 +73,7 @@ def mark_stalled_steps_as_failed(
return 0
logger.warning(
f"Found {len(stalled_steps)} stalled step(s) that exceeded " f"{timeout_seconds}s timeout. Marking as failed."
f"Found {len(stalled_steps)} stalled step(s) that exceeded {timeout_seconds}s timeout. Marking as failed."
)
count = 0
+22 -85
View File
@@ -104,33 +104,30 @@ upload_to_vcs_release = true
upload_to_pypi = false
upload_to_repository = false
# Black configuration
[tool.black]
# Ruff configuration
[tool.ruff]
line-length = 120
target-version = ['py311']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| build
| dist
| migrations
)/
'''
target-version = "py311"
# isort configuration
[tool.isort]
profile = "black"
line_length = 120
skip_gitignore = true
known_first_party = ["app"]
sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"]
[tool.ruff.lint]
# Enable Pyflakes (`F`), pycodestyle (`E`, `W`), isort (`I`), bandit (`S`), flake8-bugbear (`B`), and pylint (`PL`)
select = ["E", "F", "W", "I", "S", "B", "PL"]
ignore = [
"E501", # Line too long (handled by formatter)
"S108", # Hardcoded temp file (common pattern)
"S105", # Hardcoded password string (false positives with 'password' variable names)
"S106", # Hardcoded password func arg (false positives)
"PLC0415", # Import outside top-level (common in FastAPI/Celery)
"PLR0913", # Too many arguments
"PLR0912", # Too many branches
"PLR0915", # Too many statements
"PLR0911", # Too many return statements
"PLR2004", # Magic value comparison
"PLW0603", # Global statement
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "S110", "B017"] # Allow assert, try-except-pass, assert-raises-exception in tests
# pytest configuration
[tool.pytest.ini_options]
@@ -195,66 +192,6 @@ disable_error_code = [
"call-arg", # Dynamic call signatures in framework code
]
# pylint configuration
[tool.pylint.format]
max-line-length = 120
[tool.pylint."messages control"]
disable = [
"C0111", # missing-docstring (already documented functions use docstrings selectively)
"C0103", # invalid-name (project uses domain-specific naming conventions)
"C0114", # missing-module-docstring
"C0115", # missing-class-docstring
"C0116", # missing-function-docstring
"C0415", # import-outside-toplevel (common pattern in FastAPI/Celery)
"C0200", # consider-using-enumerate
"C0201", # consider-iterating-dictionary
"C0206", # consider-using-dict-items
"C0207", # use-maxsplit-arg
"C0123", # unidiomatic-typecheck
"C0302", # too-many-lines
"R0801", # duplicate-code (intentional patterns across storage providers)
"R0401", # cyclic-import (FastAPI app structure with lazy imports)
"R0903", # too-few-public-methods
"R0911", # too-many-return-statements
"R0912", # too-many-branches
"R0913", # too-many-arguments
"R0914", # too-many-locals
"R0915", # too-many-statements
"R0916", # too-many-boolean-expressions
"R0917", # too-many-positional-arguments
"R1702", # too-many-nested-blocks
"R1705", # no-else-return
"R1710", # inconsistent-return-statements
"R1718", # consider-using-set-comprehension
"R1720", # no-else-raise
"R1723", # no-else-break
"R1732", # consider-using-with
"W0105", # pointless-string-statement
"W0212", # protected-access
"W0223", # abstract-method
"W0404", # reimported
"W0511", # fixme (TODO comments are acceptable)
"W0603", # global-statement
"W0611", # unused-import (managed by flake8/isort)
"W0613", # unused-argument (common with framework callbacks)
"W0621", # redefined-outer-name
"W0641", # possibly-unused-variable
"W0707", # raise-missing-from
"W0718", # broad-exception-caught (intentional in error handlers)
"W0719", # broad-exception-raised
"W1203", # logging-fstring-interpolation (project uses f-strings consistently)
"W1510", # subprocess-run-check
"W1514", # unspecified-encoding
"W0612", # unused-variable
"E0213", # no-self-argument (Pydantic validators use cls)
"E0611", # no-name-in-module (false positives with package imports)
"E1101", # no-member (false positives with dynamic API clients)
"E1102", # not-callable (false positives with SQLAlchemy func.now())
"E1133", # not-an-iterable (false positives with Pydantic fields)
"E1135", # unsupported-membership-test (false positives with Pydantic fields)
]
# Coverage configuration
[tool.coverage.run]
source = ["app"]
+2 -7
View File
@@ -14,19 +14,14 @@ redis>=4.5.0 # For Redis integration tests
boto3>=1.26.0 # For S3 integration tests
# Code quality
flake8>=7.0.0
black>=24.0.0
ruff>=0.3.0
mypy>=1.8.0
pylint>=3.0.0
isort>=5.13.0
# Type stubs for mypy
types-requests>=2.31.0
types-paramiko>=3.0.0
# Security scanning
bandit>=1.7.6
safety>=3.0.0
# Security scanning (Ruff includes most security checks from bandit)
# Pre-commit hooks
pre-commit>=3.6.0
+3 -2
View File
@@ -280,6 +280,7 @@ def pytest_configure(config):
config.addinivalue_line("markers", "requires_docker: Tests requiring Docker")
config.addinivalue_line("markers", "e2e: End-to-end tests with full infrastructure")
# Import OAuth fixtures (must be at end to avoid circular imports)
try:
from tests.conftest_oauth import (
@@ -290,11 +291,11 @@ try:
test_user_info,
use_real_oauth,
)
# Make fixtures available
__all__ = [
"mock_oauth_server",
"oauth_config",
"oauth_config",
"oauth_enabled_app",
"oauth_test_token",
"test_user_info",
+28 -27
View File
@@ -15,21 +15,23 @@ import pytest
from tests.mock_oauth_server import MockOAuth2ServerContainer, create_test_userinfo
# Check if we should use real OAuth credentials from environment
_REAL_OAUTH_AVAILABLE = all([
os.environ.get("AUTHENTIK_CLIENT_ID") not in {"", "NOT_SET", "test-key", None},
os.environ.get("AUTHENTIK_CLIENT_SECRET") not in {"", "NOT_SET", "test-key", None},
os.environ.get("AUTHENTIK_CONFIG_URL") not in {"", "NOT_SET", "test-key", None},
])
_REAL_OAUTH_AVAILABLE = all(
[
os.environ.get("AUTHENTIK_CLIENT_ID") not in {"", "NOT_SET", "test-key", None},
os.environ.get("AUTHENTIK_CLIENT_SECRET") not in {"", "NOT_SET", "test-key", None},
os.environ.get("AUTHENTIK_CONFIG_URL") not in {"", "NOT_SET", "test-key", None},
]
)
@pytest.fixture(scope="session")
def use_real_oauth() -> bool:
"""
Determine if tests should use real OAuth credentials.
Returns True if valid OAuth credentials are available in the environment
(typically from GitHub Actions secrets).
Returns:
bool: True if real OAuth should be used, False for mock
"""
@@ -38,7 +40,7 @@ def use_real_oauth() -> bool:
return True
if os.environ.get("USE_MOCK_OAUTH", "").lower() in ("true", "1", "yes"):
return False
return _REAL_OAUTH_AVAILABLE
@@ -46,10 +48,10 @@ def use_real_oauth() -> bool:
def mock_oauth_server() -> Generator[MockOAuth2ServerContainer, None, None]:
"""
Provide a mock OAuth2/OIDC server for testing.
This fixture starts a mock-oauth2-server container that provides
a complete OIDC provider with all necessary endpoints.
Yields:
MockOAuth2ServerContainer: Running mock OAuth server
"""
@@ -57,7 +59,7 @@ def mock_oauth_server() -> Generator[MockOAuth2ServerContainer, None, None]:
if not _REAL_OAUTH_AVAILABLE or os.environ.get("USE_MOCK_OAUTH", "").lower() in ("true", "1", "yes"):
container = MockOAuth2ServerContainer()
container.start()
try:
# Wait for the server to be ready
container.wait_for_ready()
@@ -72,13 +74,13 @@ def mock_oauth_server() -> Generator[MockOAuth2ServerContainer, None, None]:
def oauth_config(mock_oauth_server: Optional[MockOAuth2ServerContainer], use_real_oauth: bool) -> Dict[str, str]:
"""
Provide OAuth configuration for tests.
Returns either mock OAuth config or real OAuth config based on availability.
Args:
mock_oauth_server: Mock OAuth server fixture (may be None if using real)
use_real_oauth: Whether to use real OAuth credentials
Returns:
Dictionary with OAuth configuration
"""
@@ -95,7 +97,7 @@ def oauth_config(mock_oauth_server: Optional[MockOAuth2ServerContainer], use_rea
# Use mock OAuth server
if mock_oauth_server is None:
pytest.fail("Mock OAuth server not available and real credentials not configured")
config = mock_oauth_server.get_config()
return {
"client_id": "test-client-id",
@@ -114,7 +116,7 @@ def oauth_config(mock_oauth_server: Optional[MockOAuth2ServerContainer], use_rea
def test_user_info() -> Dict:
"""
Provide test user information for OAuth flows.
Returns:
Dictionary with test user claims
"""
@@ -135,25 +137,25 @@ def oauth_test_token(
) -> Optional[str]:
"""
Generate a test OAuth token.
For mock mode: Creates a valid JWT from the mock server.
For real mode: Skips (would need real authentication flow).
Args:
mock_oauth_server: Mock OAuth server
test_user_info: User information to include in token
use_real_oauth: Whether using real OAuth
Returns:
JWT token string or None if using real OAuth
"""
if use_real_oauth:
# Can't generate tokens for real OAuth - would need actual auth flow
return None
if mock_oauth_server is None:
pytest.fail("Mock OAuth server not available")
# Create a token with the test user info
return mock_oauth_server.create_token(
subject=test_user_info["sub"],
@@ -171,21 +173,20 @@ def oauth_test_token(
def oauth_enabled_app(oauth_config: Dict[str, str]):
"""
Configure the FastAPI app with OAuth enabled for testing.
This fixture temporarily enables OAuth and configures it with the
test OAuth provider (mock or real).
Args:
oauth_config: OAuth configuration
Yields:
Configured test client
"""
import os
from app.main import app
import app.auth as auth_module
from app.auth import login, oauth_login, oauth_callback, auth, logout
from app.auth import auth, login, logout, oauth_callback, oauth_login
from app.main import app
# Save original state
original_auth_enabled = auth_module.AUTH_ENABLED
-1
View File
@@ -14,7 +14,6 @@ These tests exercise the full application stack end-to-end.
import os
import time
from pathlib import Path
from typing import Generator
import pytest
+18 -20
View File
@@ -8,11 +8,9 @@ and userinfo endpoints.
This allows for realistic OAuth testing without requiring a real IdP.
"""
import json
import logging
import time
from typing import Dict, Optional
from urllib.parse import urljoin
import requests
from testcontainers.core.container import DockerContainer
@@ -23,7 +21,7 @@ logger = logging.getLogger(__name__)
class MockOAuth2ServerContainer(DockerContainer):
"""
Testcontainer for mock-oauth2-server.
Provides a complete OIDC provider for testing OAuth2 flows.
"""
@@ -35,7 +33,7 @@ class MockOAuth2ServerContainer(DockerContainer):
):
"""
Initialize the mock OAuth2 server container.
Args:
image: Docker image to use
port: Internal container port (default 8080)
@@ -79,7 +77,7 @@ class MockOAuth2ServerContainer(DockerContainer):
def wait_for_ready(self, timeout: int = 30) -> None:
"""
Wait for the OAuth server to be ready by checking the well-known endpoint.
Args:
timeout: Maximum time to wait in seconds
"""
@@ -93,13 +91,13 @@ class MockOAuth2ServerContainer(DockerContainer):
except requests.exceptions.RequestException:
pass
time.sleep(0.5)
raise TimeoutError(f"Mock OAuth2 server did not become ready within {timeout}s")
def get_config(self) -> Dict[str, str]:
"""
Get the OAuth configuration for the mock server.
Returns:
Dictionary with OAuth endpoints and configuration
"""
@@ -121,36 +119,36 @@ class MockOAuth2ServerContainer(DockerContainer):
) -> str:
"""
Create a mock JWT token.
The mock-oauth2-server will generate a valid JWT that can be verified
using its JWKS endpoint.
Args:
subject: Subject (sub) claim for the token
claims: Additional claims to include in the token
audience: Audience (aud) claim
Returns:
JWT token string
"""
if claims is None:
claims = {}
# Add standard claims
token_claims = {
"sub": subject,
"aud": audience,
**claims,
}
# The debugger endpoint expects a different format
# For simpler testing, we'll use the token endpoint directly
# with a mock authorization code flow
# Note: For actual tests, we'll mock the token exchange in the tests
# This method is mainly for documentation/example purposes
logger.info(f"Creating token for subject: {subject}")
# Return a placeholder - in actual tests we'll mock the OAuth flow
return f"mock-token-{subject}"
@@ -164,20 +162,20 @@ def create_test_userinfo(
) -> Dict:
"""
Create a test userinfo response.
Args:
sub: Subject identifier
email: User email address
name: Full name
preferred_username: Username
groups: List of group names
Returns:
Dictionary with userinfo claims
"""
if groups is None:
groups = ["admin"]
return {
"sub": sub,
"email": email,
@@ -197,9 +195,9 @@ def configure_mock_oauth_response(
) -> None:
"""
Configure the mock OAuth server to return specific responses for a code.
This is useful for testing the OAuth callback flow.
Args:
container: The mock OAuth server container
code: Authorization code to configure
@@ -208,7 +206,7 @@ def configure_mock_oauth_response(
"""
if userinfo is None:
userinfo = create_test_userinfo()
# The mock-oauth2-server automatically handles code exchange
# and returns the configured userinfo
# This is a placeholder for any additional configuration needed
+6 -4
View File
@@ -1,8 +1,8 @@
"""Comprehensive unit tests for app/api/azure.py module."""
import pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
import pytest
@pytest.mark.unit
@@ -65,9 +65,10 @@ class TestAzureTestConnection:
@patch("app.api.azure.azure.core.exceptions.ClientAuthenticationError")
def test_azure_connection_authentication_error(self, mock_auth_error, mock_admin_client_class):
"""Test connection with authentication error."""
from app.config import settings
import azure.core.exceptions
from app.config import settings
mock_admin_client_class.side_effect = azure.core.exceptions.ClientAuthenticationError("Invalid key")
with patch.object(settings, "azure_endpoint", "https://test.cognitiveservices.azure.com/"):
@@ -79,9 +80,10 @@ class TestAzureTestConnection:
@patch("app.api.azure.DocumentIntelligenceAdministrationClient")
def test_azure_connection_service_request_error(self, mock_admin_client_class):
"""Test connection with service request error."""
from app.config import settings
import azure.core.exceptions
from app.config import settings
mock_admin_client_class.side_effect = azure.core.exceptions.ServiceRequestError("Cannot reach endpoint")
with patch.object(settings, "azure_endpoint", "https://test.cognitiveservices.azure.com/"):
+3 -4
View File
@@ -1,8 +1,9 @@
"""Comprehensive unit tests for app/api/diagnostic.py module."""
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch
@pytest.mark.unit
@@ -231,9 +232,7 @@ class TestTestNotification:
mock_send.return_value = True
with patch.object(
settings, "notification_urls", ["https://ntfy.sh/test1", "https://ntfy.sh/test2"]
):
with patch.object(settings, "notification_urls", ["https://ntfy.sh/test1", "https://ntfy.sh/test2"]):
# Response should indicate 2 services
pass
+1 -2
View File
@@ -4,8 +4,7 @@ Tests for app/api/dropbox.py module.
Covers Dropbox OAuth endpoints, settings management, and token testing.
"""
import os
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
import requests
+2 -6
View File
@@ -1,8 +1,8 @@
"""Comprehensive unit tests for app/api/dropbox.py module."""
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import MagicMock, patch, Mock
from fastapi import HTTPException
@pytest.mark.unit
@@ -53,21 +53,18 @@ class TestUpdateDropboxSettings:
def test_update_settings_refresh_token(self):
"""Test updating only refresh token."""
from app.config import settings
# Should update settings.dropbox_refresh_token
pass
def test_update_settings_all_fields(self):
"""Test updating all Dropbox settings."""
from app.config import settings
# Should update all fields: refresh_token, app_key, app_secret, folder_path
pass
def test_update_settings_partial_fields(self):
"""Test updating some fields (not all)."""
from app.config import settings
# Should only update provided fields
pass
@@ -275,7 +272,6 @@ class TestSaveDropboxSettings:
@patch("os.path.exists")
def test_save_settings_updates_memory(self, mock_exists, mock_open):
"""Test that in-memory settings are updated."""
from app.config import settings
mock_exists.return_value = True
mock_file = MagicMock()
+88 -58
View File
@@ -5,9 +5,8 @@ Tests all API endpoints with success and error cases, proper mocking, and edge c
Target: Bring coverage from 11.75% to 70%+
"""
import os
from io import BytesIO
from unittest.mock import Mock, MagicMock, patch
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
@@ -38,14 +37,14 @@ class TestListFilesAPI:
original_filename="test1.pdf",
local_filename="/tmp/test1.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="test2.pdf",
local_filename="/tmp/test2.pdf",
file_size=2048,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file1)
db_session.add(file2)
@@ -66,7 +65,7 @@ class TestListFilesAPI:
original_filename=f"test{i}.pdf",
local_filename=f"/tmp/test{i}.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -93,14 +92,14 @@ class TestListFilesAPI:
original_filename="invoice.pdf",
local_filename="/tmp/invoice.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="receipt.pdf",
local_filename="/tmp/receipt.pdf",
file_size=2048,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file1)
db_session.add(file2)
@@ -119,14 +118,14 @@ class TestListFilesAPI:
original_filename="doc.pdf",
local_filename="/tmp/doc.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="image.jpg",
local_filename="/tmp/image.jpg",
file_size=2048,
mime_type="image/jpeg"
mime_type="image/jpeg",
)
db_session.add(file1)
db_session.add(file2)
@@ -140,8 +139,20 @@ class TestListFilesAPI:
def test_list_files_sorting_asc(self, client: TestClient, db_session):
"""Test ascending sort order."""
file1 = FileRecord(filehash="hash1", original_filename="aaa.pdf", local_filename="/tmp/aaa.pdf", file_size=1024, mime_type="application/pdf")
file2 = FileRecord(filehash="hash2", original_filename="zzz.pdf", local_filename="/tmp/zzz.pdf", file_size=2048, mime_type="application/pdf")
file1 = FileRecord(
filehash="hash1",
original_filename="aaa.pdf",
local_filename="/tmp/aaa.pdf",
file_size=1024,
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="zzz.pdf",
local_filename="/tmp/zzz.pdf",
file_size=2048,
mime_type="application/pdf",
)
db_session.add(file1)
db_session.add(file2)
db_session.commit()
@@ -154,8 +165,20 @@ class TestListFilesAPI:
def test_list_files_sorting_desc(self, client: TestClient, db_session):
"""Test descending sort order."""
file1 = FileRecord(filehash="hash1", original_filename="aaa.pdf", local_filename="/tmp/aaa.pdf", file_size=1024, mime_type="application/pdf")
file2 = FileRecord(filehash="hash2", original_filename="zzz.pdf", local_filename="/tmp/zzz.pdf", file_size=2048, mime_type="application/pdf")
file1 = FileRecord(
filehash="hash1",
original_filename="aaa.pdf",
local_filename="/tmp/aaa.pdf",
file_size=1024,
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="zzz.pdf",
local_filename="/tmp/zzz.pdf",
file_size=2048,
mime_type="application/pdf",
)
db_session.add(file1)
db_session.add(file2)
db_session.commit()
@@ -178,7 +201,7 @@ class TestGetFileDetails:
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -205,7 +228,7 @@ class TestGetFileDetails:
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -216,7 +239,7 @@ class TestGetFileDetails:
task_id="task123",
step_name="process_document",
status="success",
message="Processing completed"
message="Processing completed",
)
db_session.add(log)
db_session.commit()
@@ -241,7 +264,7 @@ class TestDeleteFileRecord:
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -263,7 +286,7 @@ class TestDeleteFileRecord:
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -286,8 +309,20 @@ class TestBulkDeleteFiles:
@patch("app.config.settings.allow_file_delete", True)
def test_bulk_delete_success(self, client: TestClient, db_session):
"""Test bulk deletion of multiple files."""
file1 = FileRecord(filehash="hash1", original_filename="test1.pdf", local_filename="/tmp/test1.pdf", file_size=1024, mime_type="application/pdf")
file2 = FileRecord(filehash="hash2", original_filename="test2.pdf", local_filename="/tmp/test2.pdf", file_size=2048, mime_type="application/pdf")
file1 = FileRecord(
filehash="hash1",
original_filename="test1.pdf",
local_filename="/tmp/test1.pdf",
file_size=1024,
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="test2.pdf",
local_filename="/tmp/test2.pdf",
file_size=2048,
mime_type="application/pdf",
)
db_session.add(file1)
db_session.add(file2)
db_session.commit()
@@ -326,13 +361,13 @@ class TestBulkReprocessFiles:
# Create files with existing local files
file1_path = tmp_path / "test1.pdf"
file1_path.write_bytes(b"%PDF-1.4")
file1 = FileRecord(
filehash="hash1",
original_filename="test1.pdf",
local_filename=str(file1_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file1)
db_session.commit()
@@ -357,7 +392,7 @@ class TestBulkReprocessFiles:
original_filename="test.pdf",
local_filename="/nonexistent/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -385,13 +420,13 @@ class TestReprocessSingleFile:
# Create file with existing local file
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(file_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -420,7 +455,7 @@ class TestReprocessSingleFile:
original_filename="test.pdf",
local_filename="/nonexistent/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -439,14 +474,14 @@ class TestReprocessWithCloudOCR:
"""Test reprocessing with forced cloud OCR."""
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(file_path),
original_file_path=str(file_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -474,7 +509,7 @@ class TestReprocessWithCloudOCR:
original_filename="test.pdf",
local_filename="/nonexistent/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -502,7 +537,7 @@ class TestRetrySubtask:
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -525,7 +560,7 @@ class TestRetrySubtask:
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -548,13 +583,13 @@ class TestFilePreview:
"""Test previewing original file."""
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(file_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -575,7 +610,7 @@ class TestFilePreview:
original_filename="test.pdf",
local_filename="/nonexistent/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -591,7 +626,7 @@ class TestFilePreview:
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -608,13 +643,13 @@ class TestFileDownload:
"""Test downloading original file."""
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(file_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -643,14 +678,13 @@ class TestUIUpload:
mock_delay.return_value = mock_task
# Create PDF content
pdf_content = b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"
pdf_content = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
with patch("app.config.settings.workdir", str(tmp_path)):
response = client.post(
"/api/ui-upload",
files={"file": ("test.pdf", BytesIO(pdf_content), "application/pdf")}
"/api/ui-upload", files={"file": ("test.pdf", BytesIO(pdf_content), "application/pdf")}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
@@ -671,11 +705,8 @@ class TestUIUpload:
image_content = b"\x89PNG\r\n\x1a\n"
with patch("app.config.settings.workdir", str(tmp_path)):
response = client.post(
"/api/ui-upload",
files={"file": ("image.png", BytesIO(image_content), "image/png")}
)
response = client.post("/api/ui-upload", files={"file": ("image.png", BytesIO(image_content), "image/png")})
assert response.status_code == 200
data = response.json()
assert "task_id" in data
@@ -690,10 +721,9 @@ class TestUIUpload:
with patch("app.config.settings.workdir", str(tmp_path)):
response = client.post(
"/api/ui-upload",
files={"file": ("large.pdf", BytesIO(large_content), "application/pdf")}
"/api/ui-upload", files={"file": ("large.pdf", BytesIO(large_content), "application/pdf")}
)
assert response.status_code == 413
assert "too large" in response.json()["detail"].lower()
@@ -712,9 +742,9 @@ class TestUIUpload:
# Upload with unsafe filename
response = client.post(
"/api/ui-upload",
files={"file": ("../../../etc/passwd.pdf", BytesIO(pdf_content), "application/pdf")}
files={"file": ("../../../etc/passwd.pdf", BytesIO(pdf_content), "application/pdf")},
)
assert response.status_code == 200
data = response.json()
# Filename should be sanitized (no path traversal)
@@ -729,12 +759,12 @@ class TestExtractTextFromPDF:
def test_extract_text_from_pdf(self, tmp_path):
"""Test text extraction from PDF."""
from app.api.files import _extract_text_from_pdf
# Create a simple PDF with text
pdf_path = tmp_path / "test.pdf"
# This is a minimal PDF - in reality would have text
pdf_path.write_bytes(b"%PDF-1.4\n%%EOF")
# Should not raise exception
try:
text = _extract_text_from_pdf(str(pdf_path))
@@ -752,16 +782,16 @@ class TestRetryPipelineStep:
def test_retry_process_document_step(self, mock_delay, db_session, tmp_path):
"""Test retrying process_document step."""
from app.api.files import _retry_pipeline_step
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(file_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -778,13 +808,13 @@ class TestRetryPipelineStep:
def test_retry_unsupported_step_raises_error(self, db_session):
"""Test that unsupported step name raises error."""
from app.api.files import _retry_pipeline_step
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
+57 -97
View File
@@ -5,9 +5,8 @@ Tests all API endpoints with success and error cases, proper mocking, and edge c
Target: Bring coverage from 9.45% to 70%+
"""
import os
from datetime import datetime, timedelta
from unittest.mock import Mock, MagicMock, patch, mock_open
from unittest.mock import MagicMock, Mock, mock_open, patch
import pytest
from fastapi import HTTPException
@@ -24,7 +23,7 @@ class TestExchangeGoogleDriveToken:
mock_exchange.return_value = {
"refresh_token": "test_refresh_token",
"access_token": "test_access_token",
"expires_in": 3600
"expires_in": 3600,
}
response = client.post(
@@ -34,8 +33,8 @@ class TestExchangeGoogleDriveToken:
"client_secret": "test_client_secret",
"redirect_uri": "http://localhost/callback",
"code": "test_auth_code",
"folder_id": "test_folder"
}
"folder_id": "test_folder",
},
)
assert response.status_code == 200
@@ -52,7 +51,7 @@ class TestExchangeGoogleDriveToken:
mock_exchange.return_value = {
"refresh_token": "test_refresh_token",
"access_token": "test_access_token",
"expires_in": 3600
"expires_in": 3600,
}
response = client.post(
@@ -61,8 +60,8 @@ class TestExchangeGoogleDriveToken:
"client_id": "test_client_id",
"client_secret": "test_client_secret",
"redirect_uri": "http://localhost/callback",
"code": "test_auth_code"
}
"code": "test_auth_code",
},
)
assert response.status_code == 200
@@ -78,8 +77,8 @@ class TestExchangeGoogleDriveToken:
"client_id": "test_client_id",
"client_secret": "test_client_secret",
"redirect_uri": "http://localhost/callback",
"code": "invalid_code"
}
"code": "invalid_code",
},
)
assert response.status_code == 400
@@ -99,8 +98,8 @@ class TestUpdateGoogleDriveSettings:
"client_id": "new_client_id",
"client_secret": "new_client_secret",
"folder_id": "new_folder_id",
"use_oauth": "true"
}
"use_oauth": "true",
},
)
assert response.status_code == 200
@@ -112,11 +111,7 @@ class TestUpdateGoogleDriveSettings:
def test_update_settings_with_use_oauth_false(self, mock_settings, client: TestClient):
"""Test updating with OAuth disabled."""
response = client.post(
"/api/google-drive/update-settings",
data={
"refresh_token": "new_refresh_token",
"use_oauth": "false"
}
"/api/google-drive/update-settings", data={"refresh_token": "new_refresh_token", "use_oauth": "false"}
)
assert response.status_code == 200
@@ -124,21 +119,13 @@ class TestUpdateGoogleDriveSettings:
@patch("app.config.settings")
def test_update_settings_minimal(self, mock_settings, client: TestClient):
"""Test update with only required fields."""
response = client.post(
"/api/google-drive/update-settings",
data={
"refresh_token": "new_refresh_token"
}
)
response = client.post("/api/google-drive/update-settings", data={"refresh_token": "new_refresh_token"})
assert response.status_code == 200
def test_update_settings_missing_required_field(self, client: TestClient):
"""Test update without required refresh_token."""
response = client.post(
"/api/google-drive/update-settings",
data={}
)
response = client.post("/api/google-drive/update-settings", data={})
assert response.status_code == 422 # Validation error
@@ -160,9 +147,7 @@ class TestTestGoogleDriveToken:
# Mock the Google Drive service
mock_service = MagicMock()
mock_about = MagicMock()
mock_about.get.return_value.execute.return_value = {
"user": {"emailAddress": "test@example.com"}
}
mock_about.get.return_value.execute.return_value = {"user": {"emailAddress": "test@example.com"}}
mock_service.about.return_value = mock_about
mock_get_service.return_value = mock_service
@@ -208,7 +193,7 @@ class TestTestGoogleDriveToken:
with patch("google.oauth2.credentials.Credentials"):
response = client.get("/api/google-drive/test-token")
assert response.status_code == 200
data = response.json()
assert data["status"] == "error"
@@ -251,12 +236,13 @@ class TestGetGoogleDriveTokenInfo:
mock_creds.valid = False
mock_creds.token = "test_access_token"
mock_creds.expiry = datetime.now() + timedelta(hours=1)
# Mock refresh
def mock_refresh(request):
mock_creds.valid = True
mock_creds.refresh = mock_refresh
mock_creds_class.return_value = mock_creds
response = client.get("/api/google-drive/get-token-info")
@@ -315,18 +301,20 @@ class TestFormatTimeRemaining:
def test_format_expired_time(self):
"""Test formatting of expired time."""
from app.api.google_drive import format_time_remaining
from datetime import timedelta
from app.api.google_drive import format_time_remaining
expired = timedelta(seconds=-100)
result = format_time_remaining(expired)
assert result == "Expired"
def test_format_days_and_hours(self):
"""Test formatting with days and hours."""
from app.api.google_drive import format_time_remaining
from datetime import timedelta
from app.api.google_drive import format_time_remaining
time_left = timedelta(days=2, hours=5, minutes=30)
result = format_time_remaining(time_left)
assert "2 days" in result
@@ -335,9 +323,10 @@ class TestFormatTimeRemaining:
def test_format_hours_and_minutes(self):
"""Test formatting with hours and minutes."""
from app.api.google_drive import format_time_remaining
from datetime import timedelta
from app.api.google_drive import format_time_remaining
time_left = timedelta(hours=3, minutes=45)
result = format_time_remaining(time_left)
assert "3 hours" in result
@@ -345,18 +334,20 @@ class TestFormatTimeRemaining:
def test_format_minutes_only(self):
"""Test formatting with only minutes."""
from app.api.google_drive import format_time_remaining
from datetime import timedelta
from app.api.google_drive import format_time_remaining
time_left = timedelta(minutes=30)
result = format_time_remaining(time_left)
assert "30 minutes" in result
def test_format_single_unit(self):
"""Test singular form (1 day, not 1 days)."""
from app.api.google_drive import format_time_remaining
from datetime import timedelta
from app.api.google_drive import format_time_remaining
time_left = timedelta(days=1, hours=0)
result = format_time_remaining(time_left)
# Should use singular "day" not plural "days"
@@ -385,8 +376,8 @@ class TestSaveGoogleDriveSettings:
"client_id": "new_client_id",
"client_secret": "new_client_secret",
"folder_id": "new_folder_id",
"use_oauth": "true"
}
"use_oauth": "true",
},
)
assert response.status_code == 200
@@ -402,11 +393,7 @@ class TestSaveGoogleDriveSettings:
mock_dirname.return_value = "/app"
response = client.post(
"/api/google-drive/save-settings",
data={
"refresh_token": "new_refresh_token",
"use_oauth": "true"
}
"/api/google-drive/save-settings", data={"refresh_token": "new_refresh_token", "use_oauth": "true"}
)
assert response.status_code == 200
@@ -418,17 +405,15 @@ class TestSaveGoogleDriveSettings:
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
def test_save_settings_updates_existing_lines(self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient):
def test_save_settings_updates_existing_lines(
self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient
):
"""Test that existing settings are updated, not duplicated."""
mock_exists.return_value = True
mock_dirname.return_value = "/app"
response = client.post(
"/api/google-drive/save-settings",
data={
"refresh_token": "updated_token",
"use_oauth": "true"
}
"/api/google-drive/save-settings", data={"refresh_token": "updated_token", "use_oauth": "true"}
)
assert response.status_code == 200
@@ -437,18 +422,16 @@ class TestSaveGoogleDriveSettings:
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
def test_save_settings_uncomments_lines(self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient):
def test_save_settings_uncomments_lines(
self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient
):
"""Test that commented settings are uncommented when updated."""
mock_exists.return_value = True
mock_dirname.return_value = "/app"
response = client.post(
"/api/google-drive/save-settings",
data={
"refresh_token": "new_token",
"client_id": "new_client_id",
"use_oauth": "true"
}
data={"refresh_token": "new_token", "client_id": "new_client_id", "use_oauth": "true"},
)
assert response.status_code == 200
@@ -458,11 +441,7 @@ class TestSaveGoogleDriveSettings:
"""Test saving with OAuth disabled."""
with patch("os.path.exists", return_value=False):
response = client.post(
"/api/google-drive/save-settings",
data={
"refresh_token": "token",
"use_oauth": "false"
}
"/api/google-drive/save-settings", data={"refresh_token": "token", "use_oauth": "false"}
)
assert response.status_code == 200
@@ -471,17 +450,15 @@ class TestSaveGoogleDriveSettings:
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
def test_save_settings_file_write_error_continues(self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient):
def test_save_settings_file_write_error_continues(
self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient
):
"""Test that file write errors don't prevent in-memory update."""
mock_exists.return_value = True
mock_dirname.return_value = "/app"
response = client.post(
"/api/google-drive/save-settings",
data={
"refresh_token": "new_token",
"use_oauth": "true"
}
"/api/google-drive/save-settings", data={"refresh_token": "new_token", "use_oauth": "true"}
)
# Should still succeed with in-memory update
@@ -489,12 +466,7 @@ class TestSaveGoogleDriveSettings:
def test_save_settings_missing_required_field(self, client: TestClient):
"""Test save without required refresh_token."""
response = client.post(
"/api/google-drive/save-settings",
data={
"use_oauth": "true"
}
)
response = client.post("/api/google-drive/save-settings", data={"use_oauth": "true"})
assert response.status_code == 422 # Validation error
@@ -504,10 +476,7 @@ class TestSaveGoogleDriveSettings:
with patch("os.path.exists", return_value=False):
response = client.post(
"/api/google-drive/save-settings",
data={
"refresh_token": "existing_token",
"folder_id": "new_folder_id"
}
data={"refresh_token": "existing_token", "folder_id": "new_folder_id"},
)
assert response.status_code == 200
@@ -519,13 +488,7 @@ class TestSaveGoogleDriveSettings:
"""Test exception handling in save settings."""
mock_exists.side_effect = Exception("Unexpected error")
response = client.post(
"/api/google-drive/save-settings",
data={
"refresh_token": "token",
"use_oauth": "true"
}
)
response = client.post("/api/google-drive/save-settings", data={"refresh_token": "token", "use_oauth": "true"})
assert response.status_code == 500
data = response.json()
@@ -549,7 +512,7 @@ class TestGoogleDriveIntegration:
mock_exchange.return_value = {
"refresh_token": "new_refresh_token",
"access_token": "new_access_token",
"expires_in": 3600
"expires_in": 3600,
}
response = client.post(
@@ -558,8 +521,8 @@ class TestGoogleDriveIntegration:
"client_id": "test_client_id",
"client_secret": "test_client_secret",
"redirect_uri": "http://localhost/callback",
"code": "auth_code"
}
"code": "auth_code",
},
)
assert response.status_code == 200
@@ -570,10 +533,7 @@ class TestGoogleDriveIntegration:
with patch("os.path.exists", return_value=False):
response = client.post(
"/api/google-drive/update-settings",
data={
"refresh_token": token_data["refresh_token"],
"use_oauth": "true"
}
data={"refresh_token": token_data["refresh_token"], "use_oauth": "true"},
)
assert response.status_code == 200
+3 -4
View File
@@ -1,8 +1,9 @@
"""Comprehensive unit tests for app/api/google_drive.py module."""
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.unit
@@ -39,14 +40,12 @@ class TestUpdateGoogleDriveSettings:
def test_update_settings_oauth_enabled(self):
"""Test updating settings with OAuth enabled."""
from app.config import settings
# Should update OAuth credentials
pass
def test_update_settings_oauth_disabled(self):
"""Test updating settings with OAuth disabled."""
from app.config import settings
# Should set use_oauth to False
pass
-2
View File
@@ -1,7 +1,5 @@
"""Tests for app/api/logs.py module."""
from datetime import datetime
import pytest
from app.models import FileRecord, ProcessingLog
+55 -119
View File
@@ -5,9 +5,8 @@ Tests all API endpoints with success and error cases, proper mocking, and edge c
Target: Bring coverage from 10.51% to 70%+
"""
import os
from datetime import datetime, timedelta
from unittest.mock import Mock, MagicMock, patch, mock_open
from datetime import timedelta
from unittest.mock import Mock, mock_open, patch
import pytest
from fastapi import HTTPException
@@ -24,7 +23,7 @@ class TestExchangeOneDriveToken:
mock_exchange.return_value = {
"refresh_token": "test_refresh_token",
"access_token": "test_access_token",
"expires_in": 3600
"expires_in": 3600,
}
response = client.post(
@@ -34,8 +33,8 @@ class TestExchangeOneDriveToken:
"client_secret": "test_client_secret",
"redirect_uri": "http://localhost/callback",
"code": "test_auth_code",
"tenant_id": "common"
}
"tenant_id": "common",
},
)
assert response.status_code == 200
@@ -51,7 +50,7 @@ class TestExchangeOneDriveToken:
mock_exchange.return_value = {
"refresh_token": "test_refresh_token",
"access_token": "test_access_token",
"expires_in": 3600
"expires_in": 3600,
}
response = client.post(
@@ -61,8 +60,8 @@ class TestExchangeOneDriveToken:
"client_secret": "test_client_secret",
"redirect_uri": "http://localhost/callback",
"code": "test_auth_code",
"tenant_id": "specific-tenant-id"
}
"tenant_id": "specific-tenant-id",
},
)
assert response.status_code == 200
@@ -82,8 +81,8 @@ class TestExchangeOneDriveToken:
"client_secret": "test_client_secret",
"redirect_uri": "http://localhost/callback",
"code": "invalid_code",
"tenant_id": "common"
}
"tenant_id": "common",
},
)
assert response.status_code == 400
@@ -95,7 +94,7 @@ class TestExchangeOneDriveToken:
data={
"client_id": "test_client_id"
# Missing other required fields
}
},
)
assert response.status_code == 422 # Validation error
@@ -120,19 +119,13 @@ class TestTestOneDriveToken:
# Mock token refresh response
mock_post_response = Mock()
mock_post_response.status_code = 200
mock_post_response.json.return_value = {
"access_token": "test_access_token",
"expires_in": 3600
}
mock_post_response.json.return_value = {"access_token": "test_access_token", "expires_in": 3600}
mock_post.return_value = mock_post_response
# Mock user info response
mock_get_response = Mock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"displayName": "Test User",
"userPrincipalName": "test@example.com"
}
mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"}
mock_get.return_value = mock_get_response
response = client.get("/api/onedrive/test-token")
@@ -198,17 +191,14 @@ class TestTestOneDriveToken:
mock_post_response.json.return_value = {
"access_token": "test_access_token",
"refresh_token": "new_refresh_token", # New token
"expires_in": 3600
"expires_in": 3600,
}
mock_post.return_value = mock_post_response
# Mock user info
mock_get_response = Mock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"displayName": "Test User",
"userPrincipalName": "test@example.com"
}
mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"}
mock_get.return_value = mock_get_response
with patch("os.path.exists", return_value=False):
@@ -223,7 +213,9 @@ class TestTestOneDriveToken:
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
def test_test_token_updates_env_file(self, mock_settings, mock_dirname, mock_exists, mock_file, mock_get, mock_post, client: TestClient):
def test_test_token_updates_env_file(
self, mock_settings, mock_dirname, mock_exists, mock_file, mock_get, mock_post, client: TestClient
):
"""Test that new refresh token is saved to .env file."""
mock_settings.onedrive_refresh_token = "old_token"
mock_settings.onedrive_client_id = "test_client_id"
@@ -239,17 +231,14 @@ class TestTestOneDriveToken:
mock_post_response.json.return_value = {
"access_token": "test_access_token",
"refresh_token": "new_token",
"expires_in": 3600
"expires_in": 3600,
}
mock_post.return_value = mock_post_response
# Mock user info
mock_get_response = Mock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"displayName": "Test User",
"userPrincipalName": "test@example.com"
}
mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"}
mock_get.return_value = mock_get_response
response = client.get("/api/onedrive/test-token")
@@ -269,10 +258,7 @@ class TestTestOneDriveToken:
# Mock successful refresh
mock_post_response = Mock()
mock_post_response.status_code = 200
mock_post_response.json.return_value = {
"access_token": "test_access_token",
"expires_in": 3600
}
mock_post_response.json.return_value = {"access_token": "test_access_token", "expires_in": 3600}
mock_post.return_value = mock_post_response
# Mock failed user info
@@ -295,8 +281,7 @@ class TestFormatTimeRemaining:
def test_format_expired_time(self):
"""Test formatting of expired time."""
from app.api.onedrive import format_time_remaining
from datetime import timedelta
expired = timedelta(seconds=-100)
result = format_time_remaining(expired)
assert result == "Expired"
@@ -304,8 +289,7 @@ class TestFormatTimeRemaining:
def test_format_days_and_hours(self):
"""Test formatting with days and hours."""
from app.api.onedrive import format_time_remaining
from datetime import timedelta
time_left = timedelta(days=2, hours=5, minutes=30)
result = format_time_remaining(time_left)
assert "2 days" in result
@@ -314,8 +298,7 @@ class TestFormatTimeRemaining:
def test_format_hours_only(self):
"""Test formatting with hours only."""
from app.api.onedrive import format_time_remaining
from datetime import timedelta
time_left = timedelta(hours=5)
result = format_time_remaining(time_left)
assert "5 hours" in result
@@ -323,8 +306,7 @@ class TestFormatTimeRemaining:
def test_format_minutes_only(self):
"""Test formatting with minutes only."""
from app.api.onedrive import format_time_remaining
from datetime import timedelta
time_left = timedelta(minutes=45)
result = format_time_remaining(time_left)
assert "45 minutes" in result
@@ -350,8 +332,8 @@ class TestSaveOneDriveSettings:
"client_id": "new_client_id",
"client_secret": "new_client_secret",
"tenant_id": "common",
"folder_path": "/Documents"
}
"folder_path": "/Documents",
},
)
assert response.status_code == 200
@@ -365,13 +347,7 @@ class TestSaveOneDriveSettings:
mock_exists.return_value = False
mock_dirname.return_value = "/app"
response = client.post(
"/api/onedrive/save-settings",
data={
"refresh_token": "token",
"tenant_id": "common"
}
)
response = client.post("/api/onedrive/save-settings", data={"refresh_token": "token", "tenant_id": "common"})
assert response.status_code == 500
data = response.json()
@@ -381,17 +357,15 @@ class TestSaveOneDriveSettings:
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
def test_save_settings_updates_existing_lines(self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient):
def test_save_settings_updates_existing_lines(
self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient
):
"""Test that existing settings are updated."""
mock_exists.return_value = True
mock_dirname.return_value = "/app"
response = client.post(
"/api/onedrive/save-settings",
data={
"refresh_token": "updated_token",
"tenant_id": "common"
}
"/api/onedrive/save-settings", data={"refresh_token": "updated_token", "tenant_id": "common"}
)
assert response.status_code == 200
@@ -400,18 +374,16 @@ class TestSaveOneDriveSettings:
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
def test_save_settings_uncomments_lines(self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient):
def test_save_settings_uncomments_lines(
self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient
):
"""Test that commented settings are uncommented."""
mock_exists.return_value = True
mock_dirname.return_value = "/app"
response = client.post(
"/api/onedrive/save-settings",
data={
"refresh_token": "token",
"client_id": "new_client_id",
"tenant_id": "common"
}
data={"refresh_token": "token", "client_id": "new_client_id", "tenant_id": "common"},
)
assert response.status_code == 200
@@ -420,30 +392,23 @@ class TestSaveOneDriveSettings:
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
def test_save_settings_adds_new_lines(self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient):
def test_save_settings_adds_new_lines(
self, mock_settings, mock_dirname, mock_exists, mock_file, client: TestClient
):
"""Test that new settings are added if not present."""
mock_exists.return_value = True
mock_dirname.return_value = "/app"
response = client.post(
"/api/onedrive/save-settings",
data={
"refresh_token": "new_token",
"folder_path": "/New/Path",
"tenant_id": "common"
}
data={"refresh_token": "new_token", "folder_path": "/New/Path", "tenant_id": "common"},
)
assert response.status_code == 200
def test_save_settings_missing_required_field(self, client: TestClient):
"""Test save without required refresh_token."""
response = client.post(
"/api/onedrive/save-settings",
data={
"tenant_id": "common"
}
)
response = client.post("/api/onedrive/save-settings", data={"tenant_id": "common"})
assert response.status_code == 422 # Validation error
@@ -453,13 +418,7 @@ class TestSaveOneDriveSettings:
"""Test exception handling in save settings."""
mock_exists.side_effect = Exception("Unexpected error")
response = client.post(
"/api/onedrive/save-settings",
data={
"refresh_token": "token",
"tenant_id": "common"
}
)
response = client.post("/api/onedrive/save-settings", data={"refresh_token": "token", "tenant_id": "common"})
assert response.status_code == 500
@@ -481,8 +440,8 @@ class TestUpdateOneDriveSettings:
"client_id": "new_client_id",
"client_secret": "new_client_secret",
"tenant_id": "common",
"folder_path": "/Documents"
}
"folder_path": "/Documents",
},
)
assert response.status_code == 200
@@ -496,11 +455,7 @@ class TestUpdateOneDriveSettings:
mock_get_token.return_value = "test_token"
response = client.post(
"/api/onedrive/update-settings",
data={
"refresh_token": "new_token",
"tenant_id": "common"
}
"/api/onedrive/update-settings", data={"refresh_token": "new_token", "tenant_id": "common"}
)
assert response.status_code == 200
@@ -512,11 +467,7 @@ class TestUpdateOneDriveSettings:
mock_get_token.side_effect = Exception("Token invalid")
response = client.post(
"/api/onedrive/update-settings",
data={
"refresh_token": "bad_token",
"tenant_id": "common"
}
"/api/onedrive/update-settings", data={"refresh_token": "bad_token", "tenant_id": "common"}
)
assert response.status_code == 200
@@ -526,12 +477,7 @@ class TestUpdateOneDriveSettings:
def test_update_settings_missing_required_field(self, client: TestClient):
"""Test update without required refresh_token."""
response = client.post(
"/api/onedrive/update-settings",
data={
"tenant_id": "common"
}
)
response = client.post("/api/onedrive/update-settings", data={"tenant_id": "common"})
assert response.status_code == 422
@@ -539,14 +485,10 @@ class TestUpdateOneDriveSettings:
def test_update_settings_exception_handling(self, mock_settings, client: TestClient):
"""Test exception handling in update settings."""
mock_settings.onedrive_refresh_token = None
with patch("app.tasks.upload_to_onedrive.get_onedrive_token", side_effect=Exception("Fatal error")):
response = client.post(
"/api/onedrive/update-settings",
data={
"refresh_token": "token",
"tenant_id": "common"
}
"/api/onedrive/update-settings", data={"refresh_token": "token", "tenant_id": "common"}
)
# Should still update settings even if test fails
@@ -615,7 +557,7 @@ class TestOneDriveIntegration:
mock_exchange.return_value = {
"refresh_token": "new_refresh_token",
"access_token": "new_access_token",
"expires_in": 3600
"expires_in": 3600,
}
response = client.post(
@@ -625,8 +567,8 @@ class TestOneDriveIntegration:
"client_secret": "test_client_secret",
"redirect_uri": "http://localhost/callback",
"code": "auth_code",
"tenant_id": "common"
}
"tenant_id": "common",
},
)
assert response.status_code == 200
@@ -636,10 +578,7 @@ class TestOneDriveIntegration:
with patch("app.tasks.upload_to_onedrive.get_onedrive_token"):
response = client.post(
"/api/onedrive/update-settings",
data={
"refresh_token": token_data["refresh_token"],
"tenant_id": "common"
}
data={"refresh_token": token_data["refresh_token"], "tenant_id": "common"},
)
assert response.status_code == 200
@@ -661,16 +600,13 @@ class TestOneDriveIntegration:
mock_post_response.json.return_value = {
"access_token": "access1",
"refresh_token": "new_token",
"expires_in": 3600
"expires_in": 3600,
}
mock_post.return_value = mock_post_response
mock_get_response = Mock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"displayName": "Test User",
"userPrincipalName": "test@example.com"
}
mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"}
mock_get.return_value = mock_get_response
with patch("os.path.exists", return_value=False):
+3 -6
View File
@@ -1,8 +1,9 @@
"""Comprehensive unit tests for app/api/onedrive.py module."""
import pytest
from datetime import timedelta
from unittest.mock import MagicMock, patch
from datetime import datetime, timedelta
import pytest
@pytest.mark.unit
@@ -285,7 +286,6 @@ class TestSaveOneDriveSettings:
@patch("os.path.exists")
def test_save_settings_updates_memory(self, mock_exists, mock_open):
"""Test that in-memory settings are updated."""
from app.config import settings
mock_exists.return_value = True
mock_file = MagicMock()
@@ -303,7 +303,6 @@ class TestUpdateOneDriveSettings:
@patch("app.tasks.upload_to_onedrive.get_onedrive_token")
def test_update_settings_success(self, mock_get_token):
"""Test successful settings update."""
from app.config import settings
mock_get_token.return_value = "access_token"
@@ -313,7 +312,6 @@ class TestUpdateOneDriveSettings:
@patch("app.tasks.upload_to_onedrive.get_onedrive_token")
def test_update_settings_token_test_failed(self, mock_get_token):
"""Test when token test fails after update."""
from app.config import settings
mock_get_token.side_effect = Exception("Token test failed")
@@ -343,7 +341,6 @@ class TestGetOneDriveFullConfig:
def test_get_full_config_env_format(self):
"""Test that env_format is generated correctly."""
from app.config import settings
# env_format should contain all settings as KEY=value
pass
+2 -3
View File
@@ -1,8 +1,8 @@
"""Comprehensive unit tests for app/api/openai.py module."""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch, Mock
@pytest.mark.unit
@@ -11,7 +11,6 @@ class TestOpenAITestConnection:
def test_openai_connection_success(self):
"""Test successful OpenAI API connection."""
import openai
from app.config import settings
with patch("openai.OpenAI") as mock_openai_class:
+1 -1
View File
@@ -1,6 +1,6 @@
"""Tests for app/api/settings.py module."""
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
+6 -14
View File
@@ -1,9 +1,10 @@
"""Comprehensive unit tests for app/api/settings.py module."""
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch
@pytest.mark.unit
@@ -51,9 +52,7 @@ class TestGetSettings:
@patch("app.api.settings.get_all_settings_from_db")
@patch("app.api.settings.get_settings_by_category")
@patch("app.api.settings.get_setting_metadata")
def test_get_settings_success(
self, mock_metadata, mock_category, mock_db_settings, client: TestClient, db_session
):
def test_get_settings_success(self, mock_metadata, mock_category, mock_db_settings, client: TestClient, db_session):
"""Test successful retrieval of settings."""
# Mock session to have admin user
mock_metadata.return_value = {"description": "Test setting", "type": "string"}
@@ -63,13 +62,9 @@ class TestGetSettings:
with patch.object(client, "get") as mock_get:
with patch("app.api.settings.settings") as mock_settings:
mock_settings.setting1 = "test_value"
# Create mock request with admin session
from starlette.testclient import TestClient as StarletteClient
response = client.get(
"/api/settings/",
cookies={"session": "admin_session"}
)
response = client.get("/api/settings/", cookies={"session": "admin_session"})
@patch("app.api.settings.get_all_settings_from_db")
def test_get_settings_database_error(self, mock_db_settings, client: TestClient, db_session):
@@ -87,7 +82,6 @@ class TestGetSetting:
@patch("app.api.settings.get_setting_metadata")
def test_get_setting_existing_key(self, mock_metadata):
"""Test retrieval of existing setting."""
from app.api.settings import get_setting
from app.config import settings
mock_metadata.return_value = {"description": "Test setting"}
@@ -263,9 +257,7 @@ class TestSettingModels:
"""Test SettingResponse model."""
from app.api.settings import SettingResponse
response = SettingResponse(
key="test_key", value="test_value", metadata={"description": "test"}
)
response = SettingResponse(key="test_key", value="test_value", metadata={"description": "test"})
assert response.key == "test_key"
assert response.value == "test_value"
assert response.metadata["description"] == "test"
+2 -3
View File
@@ -1,11 +1,10 @@
"""Integration tests for auth.py with AUTH_ENABLED=True scenarios."""
import hashlib
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
from app.auth import get_current_user, get_gravatar_url, require_login
from app.auth import get_gravatar_url
@pytest.mark.unit
+2 -2
View File
@@ -1,6 +1,6 @@
"""Comprehensive unit tests for app/auth.py module."""
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import Request, status
@@ -344,7 +344,7 @@ class TestOAuthCallback:
"""Test OAuth callback returns error when OAuth not configured."""
with patch("app.auth.AUTH_ENABLED", True):
with patch("app.auth.OAUTH_CONFIGURED", False):
from app.auth import oauth_callback, oauth_login
from app.auth import oauth_login
mock_request = MagicMock()
+1 -1
View File
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.models import FileProcessingStep, FileRecord, ProcessingLog
from app.models import FileProcessingStep, FileRecord
@pytest.mark.integration
+27 -29
View File
@@ -5,8 +5,6 @@ This module tests the Celery worker configuration, task imports, and beat schedu
"""
import pytest
from unittest.mock import MagicMock, patch, PropertyMock
from celery.schedules import crontab
@pytest.mark.unit
@@ -16,29 +14,29 @@ class TestCeleryWorkerConfig:
def test_test_task_function(self):
"""Test the test_task function returns expected value."""
from app.celery_worker import test_task
result = test_task()
assert result == "Celery is working!"
def test_celery_instance_exists(self):
"""Test that celery instance exists in module."""
from app import celery_worker
assert hasattr(celery_worker, 'celery')
assert hasattr(celery_worker, "celery")
assert celery_worker.celery is not None
def test_task_routes_exists(self):
"""Test that task routes configuration exists."""
from app import celery_worker
# Task routes should be configured
assert hasattr(celery_worker.celery.conf, 'task_routes')
assert hasattr(celery_worker.celery.conf, "task_routes")
def test_all_task_imports_successful(self):
"""Test that all task modules are imported successfully."""
# Just import the module to verify no import errors
from app import celery_worker
# Module imported successfully
assert celery_worker is not None
@@ -50,49 +48,49 @@ class TestBeatScheduleConfiguration:
def test_beat_schedule_structure(self):
"""Test that beat schedule has expected structure."""
from app.celery_worker import celery
# Beat schedule should be a dictionary
assert isinstance(celery.conf.beat_schedule, dict)
# Should include credential check tasks
assert 'check-credentials-regularly' in celery.conf.beat_schedule
assert 'check-credentials-daily' in celery.conf.beat_schedule
assert 'monitor-stalled-steps' in celery.conf.beat_schedule
assert "check-credentials-regularly" in celery.conf.beat_schedule
assert "check-credentials-daily" in celery.conf.beat_schedule
assert "monitor-stalled-steps" in celery.conf.beat_schedule
def test_credential_check_schedule(self):
"""Test credential check schedule configuration."""
from app.celery_worker import celery
schedule = celery.conf.beat_schedule.get('check-credentials-regularly')
schedule = celery.conf.beat_schedule.get("check-credentials-regularly")
assert schedule is not None
assert schedule['task'] == 'app.tasks.check_credentials.check_credentials'
assert 'schedule' in schedule
assert schedule['options']['expires'] == 240
assert schedule["task"] == "app.tasks.check_credentials.check_credentials"
assert "schedule" in schedule
assert schedule["options"]["expires"] == 240
def test_daily_credential_check_schedule(self):
"""Test daily credential check schedule."""
from app.celery_worker import celery
schedule = celery.conf.beat_schedule.get('check-credentials-daily')
schedule = celery.conf.beat_schedule.get("check-credentials-daily")
assert schedule is not None
assert schedule['task'] == 'app.tasks.check_credentials.check_credentials'
assert 'schedule' in schedule
assert schedule['options']['expires'] == 3600
assert schedule["task"] == "app.tasks.check_credentials.check_credentials"
assert "schedule" in schedule
assert schedule["options"]["expires"] == 3600
def test_monitor_stalled_steps_schedule(self):
"""Test monitor stalled steps schedule."""
from app.celery_worker import celery
schedule = celery.conf.beat_schedule.get('monitor-stalled-steps')
schedule = celery.conf.beat_schedule.get("monitor-stalled-steps")
assert schedule is not None
assert schedule['task'] == 'app.tasks.monitor_stalled_steps.monitor_stalled_steps'
assert 'schedule' in schedule
assert schedule['options']['expires'] == 55
assert schedule["task"] == "app.tasks.monitor_stalled_steps.monitor_stalled_steps"
assert "schedule" in schedule
assert schedule["options"]["expires"] == 55
def test_no_none_entries_in_beat_schedule(self):
"""Test that None entries are filtered from beat schedule."""
from app.celery_worker import celery
# No None values in beat schedule
for key, value in celery.conf.beat_schedule.items():
assert value is not None, f"Beat schedule entry '{key}' should not be None"
+1 -1
View File
@@ -2,7 +2,7 @@
import json
import os
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
-2
View File
@@ -2,8 +2,6 @@
Unit tests for configuration and security validation.
"""
import os
import pytest
from pydantic import ValidationError
+15 -15
View File
@@ -14,61 +14,61 @@ class TestConfigValidatorReexports:
def test_module_imports(self):
"""Test that config_validator module imports successfully."""
from app.utils import config_validator
assert config_validator is not None
def test_validate_email_config_reexport(self):
"""Test validate_email_config is re-exported."""
from app.utils.config_validator import validate_email_config
assert callable(validate_email_config)
def test_validate_storage_configs_reexport(self):
"""Test validate_storage_configs is re-exported."""
from app.utils.config_validator import validate_storage_configs
assert callable(validate_storage_configs)
def test_validate_notification_config_reexport(self):
"""Test validate_notification_config is re-exported."""
from app.utils.config_validator import validate_notification_config
assert callable(validate_notification_config)
def test_mask_sensitive_value_reexport(self):
"""Test mask_sensitive_value is re-exported."""
from app.utils.config_validator import mask_sensitive_value
assert callable(mask_sensitive_value)
def test_get_provider_status_reexport(self):
"""Test get_provider_status is re-exported."""
from app.utils.config_validator import get_provider_status
assert callable(get_provider_status)
def test_get_settings_for_display_reexport(self):
"""Test get_settings_for_display is re-exported."""
from app.utils.config_validator import get_settings_for_display
assert callable(get_settings_for_display)
def test_dump_all_settings_reexport(self):
"""Test dump_all_settings is re-exported."""
from app.utils.config_validator import dump_all_settings
assert callable(dump_all_settings)
def test_check_all_configs_reexport(self):
"""Test check_all_configs is re-exported."""
from app.utils.config_validator import check_all_configs
assert callable(check_all_configs)
def test_all_exports_in_all(self):
"""Test that all exports are in __all__."""
from app.utils import config_validator
expected_exports = [
"validate_email_config",
"validate_storage_configs",
@@ -79,15 +79,15 @@ class TestConfigValidatorReexports:
"dump_all_settings",
"check_all_configs",
]
assert hasattr(config_validator, '__all__')
assert hasattr(config_validator, "__all__")
for export in expected_exports:
assert export in config_validator.__all__
def test_mask_sensitive_value_functionality(self):
"""Test mask_sensitive_value actually works."""
from app.utils.config_validator import mask_sensitive_value
# Test masking a sensitive value
result = mask_sensitive_value("secret_api_key_12345")
assert result != "secret_api_key_12345"
@@ -96,10 +96,10 @@ class TestConfigValidatorReexports:
def test_get_provider_status_functionality(self):
"""Test get_provider_status returns expected structure."""
from app.utils.config_validator import get_provider_status
# Get provider status (takes no arguments)
result = get_provider_status()
# Should return a dict with provider information
assert isinstance(result, dict)
# Should have at least authentication provider
-2
View File
@@ -1,7 +1,5 @@
"""Tests for app/utils/config_validator/validators.py module."""
from unittest.mock import patch
import pytest
from app.utils.config_validator.validators import (
+1 -1
View File
@@ -1,6 +1,6 @@
"""Comprehensive unit tests for app/tasks/convert_to_pdf.py module."""
from unittest.mock import MagicMock, Mock, mock_open, patch
from unittest.mock import MagicMock, mock_open, patch
import pytest
-1
View File
@@ -1,6 +1,5 @@
"""Tests for app/tasks/convert_to_pdf.py module."""
import os
from unittest.mock import MagicMock, patch
import pytest
+1 -1
View File
@@ -1,6 +1,6 @@
"""Tests to boost coverage for various small modules."""
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
+1 -2
View File
@@ -1,6 +1,6 @@
"""Final tests to push coverage over 60%."""
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
@@ -86,7 +86,6 @@ class TestCheckCredentialsFunctions:
def test_sync_test_s3_credentials(self):
"""Test save_failure_state accepts dict."""
import os
from unittest.mock import patch
from app.tasks.check_credentials import save_failure_state
+1 -3
View File
@@ -1,6 +1,5 @@
"""Tests for app/database.py module."""
import os
from unittest.mock import MagicMock, patch
import pytest
@@ -113,8 +112,7 @@ class TestSchemaMigrations:
def test_migration_adds_detail_column(self, tmp_path):
"""Test that _run_schema_migrations adds detail column to existing tables."""
from sqlalchemy import Column, Integer, String, create_engine, text
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine, text
from app.database import _run_schema_migrations
-17
View File
@@ -22,18 +22,6 @@ try:
except ModuleNotFoundError:
_has_psycopg2 = False
from tests.fixtures_integration import (
celery_app,
celery_worker,
db_session_real,
full_infrastructure,
gotenberg_container,
minio_container,
postgres_container,
redis_container,
sftp_container,
webdav_container,
)
_TEST_CREDENTIAL = "pass" # noqa: S105
@@ -67,7 +55,6 @@ class TestEndToEndWithRedis:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
# Configure to use real WebDAV server
mock_settings.webdav_url = webdav_container["url"] + "/"
mock_settings.webdav_username = webdav_container["username"]
@@ -169,7 +156,6 @@ class TestEndToEndWithRedis:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = webdav_container["url"] + "/"
mock_settings.webdav_username = webdav_container["username"]
mock_settings.webdav_password = webdav_container["password"]
@@ -233,7 +219,6 @@ class TestEndToEndWithRedis:
patch("app.tasks.upload_to_webdav.log_task_progress"),
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
):
mock_settings.webdav_url = "http://test.com/"
mock_settings.webdav_username = "user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -357,7 +342,6 @@ class TestFullInfrastructure:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = infra["webdav"]["url"] + "/"
mock_settings.webdav_username = infra["webdav"]["username"]
mock_settings.webdav_password = infra["webdav"]["password"]
@@ -556,7 +540,6 @@ class TestProductionLikeScenarios:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = infra["webdav"]["url"] + "/"
mock_settings.webdav_username = infra["webdav"]["username"]
mock_settings.webdav_password = infra["webdav"]["password"]
-1
View File
@@ -1,7 +1,6 @@
"""Tests for app/tasks/embed_metadata_into_pdf.py module."""
import os
from unittest.mock import MagicMock, patch
import pytest
+4 -5
View File
@@ -1,9 +1,6 @@
"""Comprehensive unit tests for app/tasks/embed_metadata_into_pdf.py module."""
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, Mock, mock_open, patch
from unittest.mock import MagicMock, mock_open, patch
import pytest
@@ -257,7 +254,9 @@ class TestEmbedMetadataIntoPdf:
assert "error" in result
# Verify failure was logged
failure_calls = [call for call in mock_log_progress.call_args_list if "failure" in str(call)]
failure_calls = [
call for call in mock_log_progress.call_args_list if "failure" in str(call)
]
assert len(failure_calls) > 0
@patch("app.tasks.embed_metadata_into_pdf.finalize_document_storage")
+5 -5
View File
@@ -41,9 +41,9 @@ class TestEndpointRegistration:
# We may get other errors (401, 400, 500, etc.) due to validation or missing mocks,
# but 404 specifically means the endpoint is not registered
assert response.status_code != 404, (
f"Endpoint /api/process-url returned 404 (not found). "
f"This indicates the router is not properly registered in the application. "
f"Verify that url_upload_router is included in app/api/__init__.py"
"Endpoint /api/process-url returned 404 (not found). "
"This indicates the router is not properly registered in the application. "
"Verify that url_upload_router is included in app/api/__init__.py"
)
@patch("app.api.url_upload.requests.get")
@@ -68,8 +68,8 @@ class TestEndpointRegistration:
# Should not return 405 (Method Not Allowed)
assert response.status_code != 405, (
f"Endpoint /api/process-url returned 405 (Method Not Allowed) for POST. "
f"Verify the endpoint is decorated with @router.post()"
"Endpoint /api/process-url returned 405 (Method Not Allowed) for POST. "
"Verify the endpoint is decorated with @router.post()"
)
@patch("app.api.url_upload.requests.get")
+6 -6
View File
@@ -244,9 +244,9 @@ class TestAzureDocumentIntelligenceIntegration:
assert len(result.content) > 10, f"OCR text too short: {result.content[:50]}"
# Verify the generated text is recognizable
assert (
"Acme" in result.content or "Invoice" in result.content
), f"OCR text does not contain expected keywords: {result.content[:200]}"
assert "Acme" in result.content or "Invoice" in result.content, (
f"OCR text does not contain expected keywords: {result.content[:200]}"
)
# Retrieve the searchable PDF output
operation_id = poller.details["operation_id"]
@@ -602,9 +602,9 @@ class TestFullOCRMetadataPipeline:
# The generated invoice should be classified reasonably
doc_type = metadata["document_type"].lower()
assert any(
kw in doc_type for kw in ("invoice", "rechnung", "bill")
), f"Unexpected document_type: {metadata['document_type']}"
assert any(kw in doc_type for kw in ("invoice", "rechnung", "bill")), (
f"Unexpected document_type: {metadata['document_type']}"
)
finally:
os.unlink(pdf_path)
+30 -29
View File
@@ -72,21 +72,23 @@ class TestExtractMetadataWithGpt:
"""Test successful metadata extraction with valid GPT response."""
# Mock the OpenAI client response
mock_completion = MagicMock()
mock_completion.choices[0].message.content = json.dumps({
"filename": "2024-01-15_Invoice_Amazon",
"empfaenger": "John Doe",
"absender": "Amazon",
"correspondent": "Amazon",
"kommunikationsart": "Rechnung",
"kommunikationskategorie": "Finanz_und_Vertragsdokumente",
"document_type": "Invoice",
"tags": ["invoice", "amazon", "online-shopping"],
"language": "de",
"title": "Amazon Purchase Invoice",
"confidence_score": 95,
"reference_number": "INV-2024-001",
"monetary_amounts": ["99.99 EUR"]
})
mock_completion.choices[0].message.content = json.dumps(
{
"filename": "2024-01-15_Invoice_Amazon",
"empfaenger": "John Doe",
"absender": "Amazon",
"correspondent": "Amazon",
"kommunikationsart": "Rechnung",
"kommunikationskategorie": "Finanz_und_Vertragsdokumente",
"document_type": "Invoice",
"tags": ["invoice", "amazon", "online-shopping"],
"language": "de",
"title": "Amazon Purchase Invoice",
"confidence_score": 95,
"reference_number": "INV-2024-001",
"monetary_amounts": ["99.99 EUR"],
}
)
mock_client.chat.completions.create.return_value = mock_completion
# Set task request context directly on the Celery task
@@ -119,7 +121,9 @@ class TestExtractMetadataWithGpt:
def test_handles_json_in_backticks(self, mock_client, mock_log_progress, mock_embed_task):
"""Test extraction handles JSON wrapped in markdown code blocks."""
mock_completion = MagicMock()
mock_completion.choices[0].message.content = '```json\n{"filename": "test.pdf", "document_type": "Unknown"}\n```'
mock_completion.choices[
0
].message.content = '```json\n{"filename": "test.pdf", "document_type": "Unknown"}\n```'
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
@@ -193,7 +197,7 @@ class TestExtractMetadataWithGpt:
result = extract_metadata_with_gpt.__wrapped__(
filename="test.pdf",
cleaned_text="Sample text",
file_id=None # Not provided
file_id=None, # Not provided
)
assert result["metadata"]["filename"] == "test.pdf"
@@ -207,10 +211,9 @@ class TestExtractMetadataWithGpt:
"""Test filename validation to prevent path traversal."""
mock_completion = MagicMock()
# Try to inject a malicious filename
mock_completion.choices[0].message.content = json.dumps({
"filename": "../../../etc/passwd",
"document_type": "Invoice"
})
mock_completion.choices[0].message.content = json.dumps(
{"filename": "../../../etc/passwd", "document_type": "Invoice"}
)
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
@@ -227,10 +230,9 @@ class TestExtractMetadataWithGpt:
def test_validates_filename_with_dots(self, mock_client, mock_log_progress, mock_embed_task):
"""Test filename validation rejects '..' in filenames."""
mock_completion = MagicMock()
mock_completion.choices[0].message.content = json.dumps({
"filename": "test..invoice.pdf",
"document_type": "Invoice"
})
mock_completion.choices[0].message.content = json.dumps(
{"filename": "test..invoice.pdf", "document_type": "Invoice"}
)
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
@@ -246,10 +248,9 @@ class TestExtractMetadataWithGpt:
def test_accepts_valid_filename(self, mock_client, mock_log_progress, mock_embed_task):
"""Test that valid filenames are accepted."""
mock_completion = MagicMock()
mock_completion.choices[0].message.content = json.dumps({
"filename": "2024-01-15_Invoice_Amazon.pdf",
"document_type": "Invoice"
})
mock_completion.choices[0].message.content = json.dumps(
{"filename": "2024-01-15_Invoice_Amazon.pdf", "document_type": "Invoice"}
)
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
-1
View File
@@ -2,7 +2,6 @@
Tests for file detail view improvements including reprocessing and preview endpoints.
"""
import os
from unittest.mock import MagicMock, patch
import pytest
-10
View File
@@ -9,14 +9,10 @@ Tests the new features:
"""
import json
import os
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.models import FileRecord
@@ -38,7 +34,6 @@ def sample_metadata():
@pytest.mark.integration
def test_file_detail_page_with_metadata(client: TestClient, db_session, sample_pdf_file):
"""Test file detail page displays GPT metadata correctly"""
from app.models import FileRecord
# Create a file record with paths
file_record = FileRecord(
@@ -68,7 +63,6 @@ def test_file_detail_page_with_metadata(client: TestClient, db_session, sample_p
@pytest.mark.integration
def test_file_detail_with_gpt_metadata(client: TestClient, db_session, sample_pdf_file, sample_metadata, tmp_path):
"""Test file detail page with GPT metadata JSON"""
from app.models import FileRecord
# Create processed file path and metadata JSON
processed_file = tmp_path / "2024-01-15_Company_Invoice.pdf"
@@ -106,7 +100,6 @@ def test_file_detail_with_gpt_metadata(client: TestClient, db_session, sample_pd
@pytest.mark.integration
def test_preview_original_file_endpoint(client: TestClient, db_session, sample_pdf_file):
"""Test original file preview endpoint"""
from app.models import FileRecord
file_record = FileRecord(
filehash="test789ghi",
@@ -130,7 +123,6 @@ def test_preview_original_file_endpoint(client: TestClient, db_session, sample_p
@pytest.mark.integration
def test_preview_processed_file_endpoint(client: TestClient, db_session, sample_pdf_file, tmp_path):
"""Test processed file preview endpoint"""
from app.models import FileRecord
# Create processed file
processed_file = tmp_path / "processed.pdf"
@@ -158,7 +150,6 @@ def test_preview_processed_file_endpoint(client: TestClient, db_session, sample_
@pytest.mark.integration
def test_preview_missing_file_returns_404(client: TestClient, db_session, sample_pdf_file):
"""Test preview endpoint returns 404 when file doesn't exist"""
from app.models import FileRecord
file_record = FileRecord(
filehash="test202mno",
@@ -185,7 +176,6 @@ def test_preview_missing_file_returns_404(client: TestClient, db_session, sample
@pytest.mark.integration
def test_file_detail_shows_file_status_indicators(client: TestClient, db_session, sample_pdf_file):
"""Test file detail page shows correct status indicators for original and processed files"""
from app.models import FileRecord
file_record = FileRecord(
filehash="test303pqr",
-2
View File
@@ -2,8 +2,6 @@
Tests for file listing, pagination, filtering, and detail endpoints.
"""
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
+3 -3
View File
@@ -83,9 +83,9 @@ class TestSplitPdfBySize:
# that can cause files to exceed the target size by ~20-50%. We allow 1.5x (50%) margin.
PDF_OVERHEAD_MULTIPLIER = 1.5
for split_file in split_files:
assert (
os.path.getsize(split_file) <= max_size * PDF_OVERHEAD_MULTIPLIER
), f"Split file {split_file} should respect size limit (with PDF overhead allowance)"
assert os.path.getsize(split_file) <= max_size * PDF_OVERHEAD_MULTIPLIER, (
f"Split file {split_file} should respect size limit (with PDF overhead allowance)"
)
# Cleanup split files
for split_file in split_files:
+1 -3
View File
@@ -7,14 +7,12 @@ This test module verifies that:
3. Files with completed steps show "completed" not "processing"
"""
from datetime import datetime, timedelta
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models import FileProcessingStep, FileRecord
from app.models import FileRecord
from app.utils.step_manager import get_file_overall_status, get_step_summary, initialize_file_steps, update_step_status
-1
View File
@@ -26,7 +26,6 @@ def mock_celery_tasks():
patch("app.api.files.process_document") as mock_process_task,
patch("app.api.files.convert_to_pdf") as mock_convert_task,
):
# Setup default return values for .delay()
mock_task = MagicMock()
mock_task.id = "test-task-id-123"
+1 -3
View File
@@ -5,9 +5,7 @@ Tests filename sanitization and manipulation functions.
"""
import os
from datetime import datetime
from pathlib import Path
from unittest.mock import Mock, patch
from unittest.mock import Mock
import pytest
+3 -1
View File
@@ -102,7 +102,9 @@ class TestFinalizeDocumentStorage:
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=50000):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="doc.pdf"):
with patch("app.tasks.finalize_document_storage.os.path.join", return_value="/tmp/tmp/original.pdf"):
with patch(
"app.tasks.finalize_document_storage.os.path.join", return_value="/tmp/tmp/original.pdf"
):
with patch("app.tasks.finalize_document_storage.settings") as mock_settings:
mock_settings.workdir = "/tmp"
-2
View File
@@ -2,7 +2,6 @@
import json
import os
from datetime import datetime, timezone
from email.message import EmailMessage
from unittest.mock import MagicMock, patch
@@ -11,7 +10,6 @@ import pytest
from app.tasks.imap_tasks import (
fetch_attachments_and_enqueue,
find_all_mail_xlist,
load_processed_emails,
save_processed_emails,
)
-1
View File
@@ -1,6 +1,5 @@
"""Tests for app/tasks/imap_tasks.py module."""
import json
import os
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
+45 -45
View File
@@ -4,138 +4,138 @@ Tests for app/tasks/monitor_stalled_steps.py
This module tests the periodic task that monitors and recovers stalled processing steps.
"""
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import MagicMock, patch, call
from datetime import datetime
@pytest.mark.unit
class TestMonitorStalledSteps:
"""Test monitor_stalled_steps task."""
@patch('app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed')
@patch('app.tasks.monitor_stalled_steps.SessionLocal')
@patch("app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed")
@patch("app.tasks.monitor_stalled_steps.SessionLocal")
def test_monitor_stalled_steps_no_stalled(self, mock_session_local, mock_mark_stalled):
"""Test monitor_stalled_steps when no stalled steps found."""
from app.tasks.monitor_stalled_steps import monitor_stalled_steps
# Mock database session
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
# No stalled steps
mock_mark_stalled.return_value = 0
# Run task
result = monitor_stalled_steps()
# Verify result
assert result == {"recovered": 0}
mock_mark_stalled.assert_called_once_with(mock_db)
@patch('app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed')
@patch('app.tasks.monitor_stalled_steps.SessionLocal')
@patch("app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed")
@patch("app.tasks.monitor_stalled_steps.SessionLocal")
def test_monitor_stalled_steps_with_stalled(self, mock_session_local, mock_mark_stalled):
"""Test monitor_stalled_steps when stalled steps are found."""
from app.tasks.monitor_stalled_steps import monitor_stalled_steps
# Mock database session
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
# Found 3 stalled steps
mock_mark_stalled.return_value = 3
# Run task
result = monitor_stalled_steps()
# Verify result
assert result == {"recovered": 3}
mock_mark_stalled.assert_called_once_with(mock_db)
@patch('app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed')
@patch('app.tasks.monitor_stalled_steps.SessionLocal')
@patch('app.tasks.monitor_stalled_steps.logger')
@patch("app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed")
@patch("app.tasks.monitor_stalled_steps.SessionLocal")
@patch("app.tasks.monitor_stalled_steps.logger")
def test_monitor_stalled_steps_logs_recovery(self, mock_logger, mock_session_local, mock_mark_stalled):
"""Test that monitor_stalled_steps logs recovery actions."""
from app.tasks.monitor_stalled_steps import monitor_stalled_steps
# Mock database session
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
# Found 2 stalled steps
mock_mark_stalled.return_value = 2
# Run task
result = monitor_stalled_steps()
# Verify logging
mock_logger.warning.assert_called_once()
log_message = mock_logger.warning.call_args[0][0]
assert "Recovered 2 stalled step(s)" in log_message
@patch('app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed')
@patch('app.tasks.monitor_stalled_steps.SessionLocal')
@patch('app.tasks.monitor_stalled_steps.logger')
@patch("app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed")
@patch("app.tasks.monitor_stalled_steps.SessionLocal")
@patch("app.tasks.monitor_stalled_steps.logger")
def test_monitor_stalled_steps_logs_debug_when_none(self, mock_logger, mock_session_local, mock_mark_stalled):
"""Test that monitor_stalled_steps logs debug message when no stalled steps."""
from app.tasks.monitor_stalled_steps import monitor_stalled_steps
# Mock database session
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
# No stalled steps
mock_mark_stalled.return_value = 0
# Run task
result = monitor_stalled_steps()
# Verify debug logging
mock_logger.debug.assert_called_once()
log_message = mock_logger.debug.call_args[0][0]
assert "No stalled steps found" in log_message
@patch('app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed')
@patch('app.tasks.monitor_stalled_steps.SessionLocal')
@patch('app.tasks.monitor_stalled_steps.logger')
@patch("app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed")
@patch("app.tasks.monitor_stalled_steps.SessionLocal")
@patch("app.tasks.monitor_stalled_steps.logger")
def test_monitor_stalled_steps_handles_exceptions(self, mock_logger, mock_session_local, mock_mark_stalled):
"""Test that monitor_stalled_steps handles exceptions gracefully."""
from app.tasks.monitor_stalled_steps import monitor_stalled_steps
# Mock database session
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
# Simulate an exception
mock_mark_stalled.side_effect = Exception("Database error")
# Run task
result = monitor_stalled_steps()
# Verify error handling
assert result == {"error": "Database error", "recovered": 0}
mock_logger.error.assert_called_once()
@patch('app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed')
@patch('app.tasks.monitor_stalled_steps.SessionLocal')
@patch("app.tasks.monitor_stalled_steps.mark_stalled_steps_as_failed")
@patch("app.tasks.monitor_stalled_steps.SessionLocal")
def test_monitor_stalled_steps_uses_context_manager(self, mock_session_local, mock_mark_stalled):
"""Test that monitor_stalled_steps uses context manager for database session."""
from app.tasks.monitor_stalled_steps import monitor_stalled_steps
# Mock database session
mock_db = MagicMock()
mock_context = MagicMock()
mock_context.__enter__ = MagicMock(return_value=mock_db)
mock_context.__exit__ = MagicMock(return_value=False)
mock_session_local.return_value = mock_context
mock_mark_stalled.return_value = 0
# Run task
result = monitor_stalled_steps()
# Verify context manager was used
mock_context.__enter__.assert_called_once()
mock_context.__exit__.assert_called_once()
@@ -143,15 +143,15 @@ class TestMonitorStalledSteps:
def test_monitor_stalled_steps_is_celery_task(self):
"""Test that monitor_stalled_steps is registered as a Celery task."""
from app.tasks.monitor_stalled_steps import monitor_stalled_steps
# Should have task attributes
assert hasattr(monitor_stalled_steps, 'apply_async')
assert hasattr(monitor_stalled_steps, 'delay')
assert hasattr(monitor_stalled_steps, "apply_async")
assert hasattr(monitor_stalled_steps, "delay")
assert callable(monitor_stalled_steps)
def test_monitor_stalled_steps_task_name(self):
"""Test that monitor_stalled_steps has correct task name."""
from app.tasks.monitor_stalled_steps import monitor_stalled_steps
# Check task name
assert monitor_stalled_steps.name == "app.tasks.monitor_stalled_steps.monitor_stalled_steps"
+1 -1
View File
@@ -4,7 +4,7 @@ Tests for app/utils/notification.py
Tests notification utilities and URL masking.
"""
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
import pytest
+1 -2
View File
@@ -1,12 +1,11 @@
"""Tests for app/utils/notification.py module."""
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from app.utils.notification import (
_mask_sensitive_url,
init_apprise,
notify_celery_failure,
notify_credential_failure,
notify_file_processed,
+46 -56
View File
@@ -9,8 +9,9 @@ These tests use a real OIDC flow with a mock OAuth2 server to test:
- Session management
"""
from unittest.mock import patch
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
@@ -25,15 +26,13 @@ class TestOAuthLoginFlow:
# Check that OAuth option is shown
assert b"oauth" in response.content.lower() or b"sign" in response.content.lower()
def test_oauth_login_redirects_to_provider(
self, oauth_enabled_app: TestClient, oauth_config: dict
):
def test_oauth_login_redirects_to_provider(self, oauth_enabled_app: TestClient, oauth_config: dict):
"""Test that /oauth-login redirects to the OAuth provider."""
response = oauth_enabled_app.get("/oauth-login", follow_redirects=False)
# Should redirect to authorization endpoint
assert response.status_code == 302
# Redirect location should contain the authorization endpoint
location = response.headers.get("location", "")
if oauth_config["mode"] == "mock":
@@ -44,14 +43,17 @@ class TestOAuthLoginFlow:
"""Test that OAuth login fails gracefully when not configured."""
# Test with OAuth disabled
import os
original = os.environ.get("AUTH_ENABLED")
os.environ["AUTH_ENABLED"] = "False"
try:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app, base_url="http://localhost")
response = client.get("/oauth-login", follow_redirects=False)
# Should either redirect to error page or show login page
assert response.status_code in [302, 404]
@@ -77,13 +79,13 @@ class TestOAuthCallback:
"expires_in": 3600,
"userinfo": test_user_info,
}
# Simulate OAuth callback with authorization code
response = oauth_enabled_app.get(
"/oauth-callback?code=test-auth-code&state=test-state",
follow_redirects=False,
)
# Should redirect after successful login
assert response.status_code == 302
@@ -97,24 +99,22 @@ class TestOAuthCallback:
"access_token": "mock-access-token",
"userinfo": test_user_info,
}
# First, initiate OAuth flow to set up session
oauth_enabled_app.get("/oauth-login", follow_redirects=False)
# Then handle callback
response = oauth_enabled_app.get(
"/oauth-callback?code=test-auth-code",
follow_redirects=False,
)
# Should set session cookie
assert "set-cookie" in response.headers or response.status_code == 302
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_oauth_callback_with_admin_user(
self, mock_authorize, oauth_enabled_app: TestClient
):
async def test_oauth_callback_with_admin_user(self, mock_authorize, oauth_enabled_app: TestClient):
"""Test OAuth callback with admin user group."""
mock_authorize.return_value = {
"access_token": "mock-access-token",
@@ -125,20 +125,18 @@ class TestOAuthCallback:
"groups": ["admin"],
},
}
response = oauth_enabled_app.get(
"/oauth-callback?code=test-auth-code",
follow_redirects=False,
)
# Should successfully authenticate
assert response.status_code == 302
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_oauth_callback_rejects_non_admin(
self, mock_authorize, oauth_enabled_app: TestClient
):
async def test_oauth_callback_rejects_non_admin(self, mock_authorize, oauth_enabled_app: TestClient):
"""Test that OAuth callback authenticates non-admin users with is_admin=False."""
mock_authorize.return_value = {
"access_token": "mock-access-token",
@@ -149,12 +147,12 @@ class TestOAuthCallback:
"groups": ["users"], # No admin group
},
}
response = oauth_enabled_app.get(
"/oauth-callback?code=test-auth-code",
follow_redirects=False,
)
# Non-admin users are still authenticated but with is_admin=False
assert response.status_code == 302
@@ -174,46 +172,42 @@ class TestOAuthSessionManagement:
"access_token": "mock-access-token",
"userinfo": test_user_info,
}
# Authenticate
oauth_enabled_app.get("/oauth-callback?code=test-auth-code")
# Try to access a protected route (e.g., files page)
response = oauth_enabled_app.get("/files")
# Should be able to access with valid session
# Note: May redirect to login if session not properly set
assert response.status_code in [200, 302]
def test_unauthenticated_user_redirected_to_login(
self, oauth_enabled_app: TestClient
):
def test_unauthenticated_user_redirected_to_login(self, oauth_enabled_app: TestClient):
"""Test that unauthenticated users are redirected to login."""
# Try to access protected route without authentication
response = oauth_enabled_app.get("/files", follow_redirects=False)
# Should redirect to login page
if response.status_code == 302:
assert "/login" in response.headers.get("location", "")
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_logout_clears_session(
self, mock_authorize, oauth_enabled_app: TestClient, test_user_info: dict
):
async def test_logout_clears_session(self, mock_authorize, oauth_enabled_app: TestClient, test_user_info: dict):
"""Test that logout clears user session."""
# Mock successful authentication
mock_authorize.return_value = {
"access_token": "mock-access-token",
"userinfo": test_user_info,
}
# Authenticate
oauth_enabled_app.get("/oauth-callback?code=test-auth-code")
# Logout
response = oauth_enabled_app.get("/logout", follow_redirects=False)
# Should redirect after logout
assert response.status_code == 302
@@ -222,29 +216,25 @@ class TestOAuthSessionManagement:
class TestOAuthErrorHandling:
"""Test error handling in OAuth flows."""
def test_oauth_callback_without_code_shows_error(
self, oauth_enabled_app: TestClient
):
def test_oauth_callback_without_code_shows_error(self, oauth_enabled_app: TestClient):
"""Test OAuth callback without authorization code."""
response = oauth_enabled_app.get("/oauth-callback", follow_redirects=False)
# Should handle error gracefully
assert response.status_code in [302, 400]
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_oauth_callback_with_invalid_token(
self, mock_authorize, oauth_enabled_app: TestClient
):
async def test_oauth_callback_with_invalid_token(self, mock_authorize, oauth_enabled_app: TestClient):
"""Test OAuth callback with invalid token."""
# Mock token exchange failure
mock_authorize.side_effect = Exception("Invalid authorization code")
response = oauth_enabled_app.get(
"/oauth-callback?code=invalid-code",
follow_redirects=False,
)
# Should redirect to error page
assert response.status_code == 302
location = response.headers.get("location", "")
@@ -252,21 +242,19 @@ class TestOAuthErrorHandling:
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_oauth_callback_without_userinfo(
self, mock_authorize, oauth_enabled_app: TestClient
):
async def test_oauth_callback_without_userinfo(self, mock_authorize, oauth_enabled_app: TestClient):
"""Test OAuth callback when userinfo is missing."""
# Mock token without userinfo
mock_authorize.return_value = {
"access_token": "mock-access-token",
"userinfo": None,
}
response = oauth_enabled_app.get(
"/oauth-callback?code=test-auth-code",
follow_redirects=False,
)
# Should handle missing userinfo
assert response.status_code == 302
@@ -276,7 +264,7 @@ class TestOAuthErrorHandling:
class TestRealOAuthIntegration:
"""
Integration tests using real OAuth credentials from GitHub Actions secrets.
These tests are skipped unless real OAuth credentials are available.
"""
@@ -284,11 +272,12 @@ class TestRealOAuthIntegration:
"""Test that real OAuth .well-known endpoint is accessible."""
if not use_real_oauth:
pytest.skip("Real OAuth credentials not available")
import requests
response = requests.get(oauth_config["server_metadata_url"], timeout=10)
assert response.status_code == 200
config = response.json()
assert "authorization_endpoint" in config
assert "token_endpoint" in config
@@ -298,16 +287,17 @@ class TestRealOAuthIntegration:
"""Test that real OAuth JWKS endpoint is accessible."""
if not use_real_oauth:
pytest.skip("Real OAuth credentials not available")
import requests
# Get well-known config first
response = requests.get(oauth_config["server_metadata_url"], timeout=10)
config = response.json()
# Test JWKS endpoint
jwks_response = requests.get(config["jwks_uri"], timeout=10)
assert jwks_response.status_code == 200
jwks = jwks_response.json()
assert "keys" in jwks
assert len(jwks["keys"]) > 0
-15
View File
@@ -5,7 +5,6 @@ These tests verify OCR processing logic with mocked external AI/ML services
(OpenAI, Azure Document Intelligence). Tests cover typical and edge cases.
"""
import os
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -95,7 +94,6 @@ startxref
patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
patch("app.tasks.process_with_azure_document_intelligence.rotate_pdf_pages") as mock_rotate,
):
mock_settings.workdir = str(tmp_path)
mock_rotate.delay = MagicMock()
@@ -144,7 +142,6 @@ startxref
patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
patch("app.tasks.process_with_azure_document_intelligence.os.path.getsize") as mock_getsize,
):
mock_settings.workdir = str(tmp_path)
# Mock file size to be larger than 500 MB
mock_getsize.return_value = AZURE_DOC_INTELLIGENCE_LIMITS["max_file_size_bytes"] + 1024
@@ -169,7 +166,6 @@ startxref
patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
patch("app.tasks.process_with_azure_document_intelligence.get_pdf_page_count") as mock_page_count,
):
mock_settings.workdir = str(tmp_path)
# Mock page count to exceed limit
mock_page_count.return_value = AZURE_DOC_INTELLIGENCE_LIMITS["max_pages"] + 1
@@ -215,7 +211,6 @@ startxref
patch("app.tasks.process_with_azure_document_intelligence.get_pdf_page_count") as mock_page_count,
patch("app.tasks.process_with_azure_document_intelligence.rotate_pdf_pages"),
):
mock_settings.workdir = str(tmp_path)
# Return None to simulate page count determination failure
mock_page_count.return_value = None
@@ -267,7 +262,6 @@ startxref
patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
patch("app.tasks.process_with_azure_document_intelligence.rotate_pdf_pages") as mock_rotate,
):
mock_settings.workdir = str(tmp_path)
mock_rotate.delay = MagicMock()
@@ -302,7 +296,6 @@ startxref
),
patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
):
mock_settings.workdir = str(tmp_path)
# Should raise the exception
@@ -440,7 +433,6 @@ class TestRefineTextWithGPT:
patch.object(metadata_module, "extract_metadata_with_gpt") as mock_extract,
patch("app.tasks.refine_text_with_gpt.settings") as mock_settings,
):
mock_settings.openai_model = "gpt-4"
mock_extract.delay = MagicMock()
@@ -473,7 +465,6 @@ class TestRefineTextWithGPT:
patch("app.tasks.refine_text_with_gpt.client", mock_client),
patch("app.tasks.refine_text_with_gpt.settings") as mock_settings,
):
mock_settings.openai_model = "gpt-4"
# Should raise the exception
@@ -582,7 +573,6 @@ startxref
patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
):
mock_settings.workdir = str(tmp_path)
mock_extract.delay = MagicMock()
@@ -617,7 +607,6 @@ startxref
patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
):
mock_settings.workdir = str(tmp_path)
mock_extract.delay = MagicMock()
@@ -651,7 +640,6 @@ startxref
patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
):
mock_settings.workdir = str(tmp_path)
mock_extract.delay = MagicMock()
@@ -676,7 +664,6 @@ startxref
patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
):
mock_settings.workdir = str(tmp_path)
mock_extract.delay = MagicMock()
@@ -712,7 +699,6 @@ startxref
patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
):
mock_settings.workdir = str(tmp_path)
mock_extract.delay = MagicMock()
@@ -783,7 +769,6 @@ startxref
patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
):
mock_settings.workdir = str(tmp_path)
mock_extract.delay = MagicMock()
@@ -93,7 +93,6 @@ startxref
patch("app.tasks.process_document.log_task_progress"),
patch("app.tasks.process_document.extract_metadata_with_gpt") as mock_extract,
):
# Setup mocks
mock_settings.workdir = str(tmp_path)
mock_session_local.return_value.__enter__.return_value = db_session
@@ -194,7 +193,6 @@ startxref
patch("app.tasks.process_document.log_task_progress"),
patch("app.tasks.process_document.extract_metadata_with_gpt") as mock_extract,
):
# Setup mocks
mock_settings.workdir = str(tmp_path)
mock_session_local.return_value.__enter__.return_value = db_session
+1 -7
View File
@@ -4,11 +4,9 @@ Security tests for path traversal vulnerabilities.
Tests all file path operations to ensure they properly prevent path traversal attacks.
"""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
import pytest
@@ -357,7 +355,6 @@ class TestFileUploadSecurity:
def test_ui_upload_uses_basename(self):
"""Test that ui_upload extracts basename to prevent path traversal."""
import os
from app.utils.filename_utils import sanitize_filename
@@ -389,7 +386,6 @@ class TestFileUploadSecurity:
def test_sanitize_after_basename(self):
"""Test that sanitization happens after basename extraction."""
import os
from app.utils.filename_utils import sanitize_filename
@@ -443,7 +439,6 @@ class TestEndToEndPathTraversal:
def test_full_upload_flow_prevents_traversal(self, tmp_path):
"""Test complete upload flow prevents path traversal."""
import os
import uuid
from app.utils.filename_utils import sanitize_filename
@@ -474,7 +469,6 @@ class TestEndToEndPathTraversal:
def test_metadata_embedding_flow_prevents_traversal(self, tmp_path):
"""Test metadata embedding flow prevents path traversal."""
import os
from app.utils.filename_utils import sanitize_filename
-6
View File
@@ -5,11 +5,9 @@ These tests verify that the process_document task correctly handles file process
and doesn't cause DetachedInstanceError when accessing database objects.
"""
import os
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.orm import Session
from app.models import FileRecord
from app.tasks.process_document import process_document
@@ -92,7 +90,6 @@ startxref
patch("app.tasks.process_document.log_task_progress"),
patch("app.tasks.process_document.extract_metadata_with_gpt") as mock_extract,
):
# Setup mocks
mock_settings.workdir = str(tmp_path)
mock_session_local.return_value.__enter__.return_value = db_session
@@ -153,7 +150,6 @@ def test_process_document_duplicate_file(db_session, tmp_path):
patch("app.tasks.process_document.SessionLocal") as mock_session_local,
patch("app.tasks.process_document.log_task_progress"),
):
# Setup mocks
mock_session_local.return_value.__enter__.return_value = db_session
mock_session_local.return_value.__exit__.return_value = None
@@ -226,7 +222,6 @@ startxref
patch("app.tasks.process_document.log_task_progress"),
patch("app.tasks.process_document.process_with_azure_document_intelligence") as mock_azure,
):
# Setup mocks
mock_settings.workdir = str(tmp_path)
mock_session_local.return_value.__enter__.return_value = db_session
@@ -345,7 +340,6 @@ startxref
patch("app.tasks.process_document.log_task_progress"),
patch("app.tasks.process_document.extract_metadata_with_gpt") as mock_extract,
):
# Setup mocks
mock_settings.workdir = str(tmp_path)
mock_session_local.return_value.__enter__.return_value = db_session
+1 -2
View File
@@ -2,8 +2,7 @@
Tests for /processall endpoint throttling behavior.
"""
import os
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
from fastapi.testclient import TestClient
+36 -34
View File
@@ -4,9 +4,9 @@ Tests for app/middleware/rate_limit_decorators.py
This module tests the rate limiting decorators for API endpoints.
"""
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import MagicMock, patch, Mock
from fastapi import Request
@pytest.mark.unit
@@ -16,10 +16,10 @@ class TestRateLimitDecorators:
def test_get_limiter_initialization(self):
"""Test that get_limiter initializes limiter from app state."""
from app.middleware import rate_limit_decorators
# Reset the global limiter
rate_limit_decorators._limiter = None
# Try to get limiter - will import app and get limiter from state
# This test just verifies the function can be called
try:
@@ -34,128 +34,130 @@ class TestRateLimitDecorators:
def test_get_limiter_caching(self):
"""Test that get_limiter caches the limiter instance."""
from app.middleware import rate_limit_decorators
# Set up mock limiter directly
mock_limiter = MagicMock()
rate_limit_decorators._limiter = mock_limiter
# Get limiter multiple times
limiter1 = rate_limit_decorators.get_limiter()
limiter2 = rate_limit_decorators.get_limiter()
# Should return same instance
assert limiter1 is limiter2
assert limiter1 is mock_limiter
@patch('app.middleware.rate_limit_decorators.get_limiter')
@patch("app.middleware.rate_limit_decorators.get_limiter")
def test_limit_decorator(self, mock_get_limiter):
"""Test the limit decorator applies rate limit."""
from app.middleware.rate_limit_decorators import limit
# Mock limiter
mock_limiter = MagicMock()
mock_limiter.limit = MagicMock(return_value=lambda f: f)
mock_get_limiter.return_value = mock_limiter
# Create a test function
@limit("10/minute")
async def test_endpoint():
return {"message": "success"}
# Verify limiter.limit was called with correct rate
mock_limiter.limit.assert_called_once_with("10/minute")
@patch('app.middleware.rate_limit_decorators.get_limiter')
@patch("app.middleware.rate_limit_decorators.get_limiter")
def test_limit_decorator_with_different_rates(self, mock_get_limiter):
"""Test limit decorator with various rate limit strings."""
from app.middleware.rate_limit_decorators import limit
# Mock limiter
mock_limiter = MagicMock()
mock_limiter.limit = MagicMock(return_value=lambda f: f)
mock_get_limiter.return_value = mock_limiter
# Test different rate limits
rates = ["5/second", "100/hour", "1000/day"]
for rate in rates:
mock_limiter.limit.reset_mock()
@limit(rate)
async def test_endpoint():
return {"message": "success"}
mock_limiter.limit.assert_called_once_with(rate)
@patch('app.middleware.rate_limit_decorators.get_limiter')
@patch("app.middleware.rate_limit_decorators.get_limiter")
def test_exempt_decorator(self, mock_get_limiter):
"""Test the exempt decorator exempts endpoint from rate limiting."""
from app.middleware.rate_limit_decorators import exempt
# Mock limiter
mock_limiter = MagicMock()
mock_limiter.exempt = MagicMock(return_value=lambda f: f)
mock_get_limiter.return_value = mock_limiter
# Create a test function
@exempt()
async def test_endpoint():
return {"message": "success"}
# Verify limiter.exempt was called
mock_limiter.exempt.assert_called_once()
@patch('app.middleware.rate_limit_decorators.get_limiter')
@patch("app.middleware.rate_limit_decorators.get_limiter")
def test_limit_decorator_preserves_function(self, mock_get_limiter):
"""Test that limit decorator preserves the original function."""
from app.middleware.rate_limit_decorators import limit
# Mock limiter to return the function unchanged
mock_limiter = MagicMock()
mock_limiter.limit = MagicMock(return_value=lambda f: f)
mock_get_limiter.return_value = mock_limiter
# Original function
async def original_function():
return "original"
# Decorate it
@limit("10/minute")
async def decorated_function():
return "original"
# Function should still work
import asyncio
result = asyncio.run(decorated_function())
assert result == "original"
@patch('app.middleware.rate_limit_decorators.get_limiter')
@patch("app.middleware.rate_limit_decorators.get_limiter")
def test_exempt_decorator_preserves_function(self, mock_get_limiter):
"""Test that exempt decorator preserves the original function."""
from app.middleware.rate_limit_decorators import exempt
# Mock limiter to return a simple passthrough decorator
mock_limiter = MagicMock()
mock_limiter.exempt.side_effect = lambda f: f
mock_get_limiter.return_value = mock_limiter
# Decorate function
@exempt()
async def decorated_function():
return "exempted"
# Function should still work
import asyncio
result = asyncio.run(decorated_function())
assert result == "exempted"
def test_module_imports(self):
"""Test that the module can be imported without errors."""
from app.middleware import rate_limit_decorators
assert hasattr(rate_limit_decorators, 'get_limiter')
assert hasattr(rate_limit_decorators, 'limit')
assert hasattr(rate_limit_decorators, 'exempt')
assert hasattr(rate_limit_decorators, "get_limiter")
assert hasattr(rate_limit_decorators, "limit")
assert hasattr(rate_limit_decorators, "exempt")
assert callable(rate_limit_decorators.get_limiter)
assert callable(rate_limit_decorators.limit)
assert callable(rate_limit_decorators.exempt)
-4
View File
@@ -7,10 +7,7 @@ These tests validate that rate limiting is properly applied to API endpoints
to prevent abuse and DoS attacks.
"""
import time
import pytest
from fastapi import status
@pytest.mark.unit
@@ -101,7 +98,6 @@ def test_rate_limit_exceeded_returns_429(client):
@pytest.mark.security
def test_rate_limiting_uses_correct_identifier():
"""Test that rate limiting uses IP or user ID as identifier."""
from fastapi import Request
from app.middleware.rate_limit import get_identifier
+1 -2
View File
@@ -1,7 +1,6 @@
"""Tests for app/tasks/upload_with_rclone.py module."""
import os
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
+3 -3
View File
@@ -142,9 +142,9 @@ def test_x_frame_options_valid_value(client):
x_frame_value = response.headers["X-Frame-Options"]
valid_values = ["DENY", "SAMEORIGIN"]
# Note: ALLOW-FROM is deprecated in modern browsers; use CSP frame-ancestors instead
assert x_frame_value in valid_values or x_frame_value.startswith(
"ALLOW-FROM"
), f"Invalid X-Frame-Options value: {x_frame_value}"
assert x_frame_value in valid_values or x_frame_value.startswith("ALLOW-FROM"), (
f"Invalid X-Frame-Options value: {x_frame_value}"
)
@pytest.mark.integration
-2
View File
@@ -6,7 +6,6 @@ import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.config import Settings
from app.models import ApplicationSettings
from app.utils.config_loader import convert_setting_value, load_settings_from_db
from app.utils.settings_service import (
@@ -224,7 +223,6 @@ class TestSettingsPrecedence:
def test_db_overrides_default(self, db_session: Session):
"""Test that database settings override default values"""
# Create a minimal test settings object
from typing import Optional
from pydantic_settings import BaseSettings
+56 -56
View File
@@ -4,82 +4,82 @@ Tests for app/utils/step_timeout.py
This module tests step timeout detection and handling logic.
"""
import pytest
from unittest.mock import MagicMock, patch, call
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.unit
class TestStepTimeout:
"""Test step timeout utilities."""
@patch('app.utils.step_timeout.settings')
@patch("app.utils.step_timeout.settings")
def test_get_step_timeout_default(self, mock_settings):
"""Test get_step_timeout returns default value."""
from app.utils.step_timeout import get_step_timeout, DEFAULT_STEP_TIMEOUT
from app.utils.step_timeout import DEFAULT_STEP_TIMEOUT, get_step_timeout
# No custom timeout in settings
del mock_settings.step_timeout
timeout = get_step_timeout()
assert timeout == DEFAULT_STEP_TIMEOUT
assert timeout == 600
@patch('app.utils.step_timeout.settings')
@patch("app.utils.step_timeout.settings")
def test_get_step_timeout_custom(self, mock_settings):
"""Test get_step_timeout returns custom value from settings."""
from app.utils.step_timeout import get_step_timeout
# Custom timeout in settings
mock_settings.step_timeout = 300
timeout = get_step_timeout()
assert timeout == 300
@patch('app.utils.step_timeout.logger')
@patch("app.utils.step_timeout.logger")
def test_mark_stalled_steps_as_failed_no_steps(self, mock_logger):
"""Test mark_stalled_steps_as_failed when no stalled steps exist."""
from app.utils.step_timeout import mark_stalled_steps_as_failed
from app.models import FileProcessingStep
# Mock database session with proper query chain
mock_db = MagicMock()
# Set up the query chain to return empty list (single .filter() call with multiple conditions)
mock_db.query.return_value.filter.return_value.all.return_value = []
# Run function
count = mark_stalled_steps_as_failed(mock_db)
# No steps should be marked
assert count == 0
@patch('app.utils.step_timeout.logger')
@patch("app.utils.step_timeout.logger")
def test_mark_stalled_steps_as_failed_with_stalled_steps(self, mock_logger):
"""Test mark_stalled_steps_as_failed marks stalled steps."""
from app.utils.step_timeout import mark_stalled_steps_as_failed
from app.models import FileProcessingStep
from app.utils.step_timeout import mark_stalled_steps_as_failed
# Create mock stalled steps
step1 = MagicMock(spec=FileProcessingStep)
step1.file_id = 1
step1.step_name = "ocr"
step1.status = "in_progress"
step1.started_at = datetime.utcnow() - timedelta(seconds=700)
step2 = MagicMock(spec=FileProcessingStep)
step2.file_id = 2
step2.step_name = "metadata"
step2.status = "in_progress"
step2.started_at = datetime.utcnow() - timedelta(seconds=800)
# Mock database session
mock_db = MagicMock()
# Set up the query chain to return stalled steps (single .filter() call with multiple conditions)
mock_db.query.return_value.filter.return_value.all.return_value = [step1, step2]
# Run function
count = mark_stalled_steps_as_failed(mock_db)
# Both steps should be marked as failed
assert count == 2
assert step1.status == "failure"
@@ -90,63 +90,63 @@ class TestStepTimeout:
assert "timeout" in step2.error_message.lower()
mock_db.commit.assert_called_once()
@patch('app.utils.step_timeout.logger')
@patch("app.utils.step_timeout.logger")
def test_mark_stalled_steps_as_failed_custom_timeout(self, mock_logger):
"""Test mark_stalled_steps_as_failed with custom timeout."""
from app.utils.step_timeout import mark_stalled_steps_as_failed
from app.models import FileProcessingStep
from app.utils.step_timeout import mark_stalled_steps_as_failed
# Create mock step that's stalled with custom timeout
step = MagicMock(spec=FileProcessingStep)
step.file_id = 1
step.step_name = "ocr"
step.status = "in_progress"
step.started_at = datetime.utcnow() - timedelta(seconds=200) # 200 seconds ago
# Mock database session
mock_db = MagicMock()
# Set up the query chain to return stalled step (single .filter() call with multiple conditions)
mock_db.query.return_value.filter.return_value.all.return_value = [step]
# Run function with 150 second timeout
count = mark_stalled_steps_as_failed(mock_db, timeout_seconds=150)
# Step should be marked as failed
assert count == 1
assert step.status == "failure"
assert "150 seconds" in step.error_message
@patch('app.utils.step_timeout.logger')
@patch("app.utils.step_timeout.logger")
def test_mark_stalled_steps_as_failed_for_specific_file(self, mock_logger):
"""Test mark_stalled_steps_as_failed for specific file."""
from app.utils.step_timeout import mark_stalled_steps_as_failed
from app.models import FileProcessingStep
from app.utils.step_timeout import mark_stalled_steps_as_failed
# Create mock stalled step
step = MagicMock(spec=FileProcessingStep)
step.file_id = 42
step.step_name = "ocr"
step.status = "in_progress"
step.started_at = datetime.utcnow() - timedelta(seconds=700)
# Mock database session with file filter
mock_db = MagicMock()
# Set up the query chain with file filter (first .filter() for conditions, second for file_id)
mock_db.query.return_value.filter.return_value.filter.return_value.all.return_value = [step]
# Run function for specific file
count = mark_stalled_steps_as_failed(mock_db, file_id=42)
# Step should be marked as failed
assert count == 1
assert step.status == "failure"
@patch('app.utils.step_timeout.logger')
@patch("app.utils.step_timeout.logger")
def test_mark_stalled_steps_as_failed_error_message_format(self, mock_logger):
"""Test that error message includes all necessary details."""
from app.utils.step_timeout import mark_stalled_steps_as_failed
from app.models import FileProcessingStep
from app.utils.step_timeout import mark_stalled_steps_as_failed
# Create mock stalled step
started_time = datetime.utcnow() - timedelta(seconds=700)
step = MagicMock(spec=FileProcessingStep)
@@ -154,15 +154,15 @@ class TestStepTimeout:
step.step_name = "ocr"
step.status = "in_progress"
step.started_at = started_time
# Mock database session
mock_db = MagicMock()
# Set up the query chain to return stalled step (single .filter() call with multiple conditions)
mock_db.query.return_value.filter.return_value.all.return_value = [step]
# Run function
count = mark_stalled_steps_as_failed(mock_db, timeout_seconds=600)
# Check error message content
assert count == 1
error_msg = step.error_message
@@ -170,75 +170,75 @@ class TestStepTimeout:
assert "timeout" in error_msg.lower()
assert str(started_time) in error_msg
@patch('app.utils.step_timeout.logger')
@patch("app.utils.step_timeout.logger")
def test_mark_stalled_steps_as_failed_logging(self, mock_logger):
"""Test that mark_stalled_steps_as_failed logs warnings and errors."""
from app.utils.step_timeout import mark_stalled_steps_as_failed
from app.models import FileProcessingStep
from app.utils.step_timeout import mark_stalled_steps_as_failed
# Create mock stalled step
step = MagicMock(spec=FileProcessingStep)
step.file_id = 1
step.step_name = "ocr"
step.status = "in_progress"
step.started_at = datetime.utcnow() - timedelta(seconds=700)
# Mock database session
mock_db = MagicMock()
# Set up the query chain to return stalled step (single .filter() call with multiple conditions)
mock_db.query.return_value.filter.return_value.all.return_value = [step]
# Run function
count = mark_stalled_steps_as_failed(mock_db)
# Verify logging
assert count == 1
mock_logger.warning.assert_called_once()
mock_logger.error.assert_called_once()
@patch('app.utils.step_timeout.logger')
@patch("app.utils.step_timeout.logger")
def test_check_and_recover_stalled_file_found(self, mock_logger):
"""Test check_and_recover_stalled_file when stalled steps found."""
from app.utils.step_timeout import check_and_recover_stalled_file
from app.models import FileProcessingStep
from app.utils.step_timeout import check_and_recover_stalled_file
# Create mock stalled step
step = MagicMock(spec=FileProcessingStep)
step.file_id = 42
step.step_name = "ocr"
step.status = "in_progress"
step.started_at = datetime.utcnow() - timedelta(seconds=700)
# Mock database session
mock_db = MagicMock()
# Set up the query chain with file filter (first .filter() for conditions, second for file_id)
mock_db.query.return_value.filter.return_value.filter.return_value.all.return_value = [step]
# Run function
result = check_and_recover_stalled_file(mock_db, 42)
# Should return True when stalled steps found
assert result is True
@patch('app.utils.step_timeout.logger')
@patch("app.utils.step_timeout.logger")
def test_check_and_recover_stalled_file_not_found(self, mock_logger):
"""Test check_and_recover_stalled_file when no stalled steps."""
from app.utils.step_timeout import check_and_recover_stalled_file
# Mock database session with no stalled steps
mock_db = MagicMock()
# Set up the query chain with file filter (first .filter() for conditions, second for file_id)
mock_db.query.return_value.filter.return_value.filter.return_value.all.return_value = []
# Run function
result = check_and_recover_stalled_file(mock_db, 42)
# Should return False when no stalled steps
assert result is False
def test_default_step_timeout_constant(self):
"""Test that DEFAULT_STEP_TIMEOUT is defined correctly."""
from app.utils.step_timeout import DEFAULT_STEP_TIMEOUT
assert DEFAULT_STEP_TIMEOUT == 600
assert isinstance(DEFAULT_STEP_TIMEOUT, int)
-1
View File
@@ -10,7 +10,6 @@ import os
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.orm import Session
from app.models import FileRecord
-2
View File
@@ -1,7 +1,5 @@
"""Tests for app/tasks/upload_to_email.py module."""
from unittest.mock import MagicMock, patch
import pytest
-2
View File
@@ -1,7 +1,5 @@
"""Additional tests for upload_to_ftp task."""
from unittest.mock import MagicMock, patch
import pytest
+1 -17
View File
@@ -3,7 +3,7 @@ Tests for upload tasks including OneDrive, S3, FTP, SFTP, WebDAV, Google Drive,
"""
import os
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
@@ -53,7 +53,6 @@ def test_upload_to_onedrive_accepts_file_id(sample_text_file, mock_settings):
patch("app.tasks.upload_to_onedrive.upload_large_file") as mock_upload,
patch("app.tasks.upload_to_onedrive.log_task_progress"),
):
# Setup mocks
mock_token.return_value = "test_access_token"
mock_session.return_value = "https://upload.url"
@@ -77,7 +76,6 @@ def test_upload_to_onedrive_without_file_id(sample_text_file, mock_settings):
patch("app.tasks.upload_to_onedrive.upload_large_file") as mock_upload,
patch("app.tasks.upload_to_onedrive.log_task_progress"),
):
# Setup mocks
mock_token.return_value = "test_access_token"
mock_session.return_value = "https://upload.url"
@@ -97,7 +95,6 @@ def test_upload_to_s3_accepts_file_id(sample_text_file, mock_settings):
patch("app.tasks.upload_to_s3.boto3.client") as mock_boto_client,
patch("app.tasks.upload_to_s3.log_task_progress"),
):
# Setup mock S3 client
mock_s3 = Mock()
mock_boto_client.return_value = mock_s3
@@ -119,7 +116,6 @@ def test_upload_to_s3_without_file_id(sample_text_file, mock_settings):
patch("app.tasks.upload_to_s3.boto3.client") as mock_boto_client,
patch("app.tasks.upload_to_s3.log_task_progress"),
):
# Setup mock S3 client
mock_s3 = Mock()
mock_boto_client.return_value = mock_s3
@@ -157,7 +153,6 @@ def test_upload_to_onedrive_logs_with_file_id(sample_text_file, mock_settings):
patch("app.tasks.upload_to_onedrive.upload_large_file") as mock_upload,
patch("app.tasks.upload_to_onedrive.log_task_progress") as mock_log,
):
# Setup mocks
mock_token.return_value = "test_access_token"
mock_session.return_value = "https://upload.url"
@@ -182,7 +177,6 @@ def test_upload_to_s3_logs_with_file_id(sample_text_file, mock_settings):
patch("app.tasks.upload_to_s3.boto3.client") as mock_boto_client,
patch("app.tasks.upload_to_s3.log_task_progress") as mock_log,
):
# Setup mock S3 client
mock_s3 = Mock()
mock_boto_client.return_value = mock_s3
@@ -211,7 +205,6 @@ def test_upload_to_ftp_accepts_file_id(sample_text_file):
patch("app.tasks.upload_to_ftp.ftplib.FTP") as mock_ftp,
patch("app.tasks.upload_to_ftp.log_task_progress"),
):
# Setup settings
mock_settings.ftp_host = "ftp.example.com"
mock_settings.ftp_port = 21
@@ -241,7 +234,6 @@ def test_upload_to_ftp_without_file_id(sample_text_file):
patch("app.tasks.upload_to_ftp.ftplib.FTP") as mock_ftp,
patch("app.tasks.upload_to_ftp.log_task_progress"),
):
# Setup settings
mock_settings.ftp_host = "ftp.example.com"
mock_settings.ftp_username = "test_user"
@@ -270,7 +262,6 @@ def test_upload_to_sftp_accepts_file_id(sample_text_file):
patch("app.tasks.upload_to_sftp.extract_remote_path") as mock_extract,
patch("app.tasks.upload_to_sftp.get_unique_filename") as mock_unique,
):
# Setup settings
mock_settings.sftp_host = "sftp.example.com"
mock_settings.sftp_port = 22
@@ -303,7 +294,6 @@ def test_upload_to_webdav_accepts_file_id(sample_text_file):
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
# Setup settings
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
@@ -334,7 +324,6 @@ def test_upload_to_google_drive_accepts_file_id(sample_text_file):
patch("app.tasks.upload_to_google_drive.settings") as mock_settings,
patch("app.tasks.upload_to_google_drive.log_task_progress"),
):
# Setup settings
mock_settings.google_drive_folder_id = "test_folder_id"
@@ -374,7 +363,6 @@ def test_upload_to_email_accepts_file_id(sample_text_file):
patch("app.tasks.upload_to_email._send_email_with_smtp") as mock_send,
patch("app.tasks.upload_to_email.attach_logo") as mock_logo,
):
# Setup settings
mock_settings.email_host = "smtp.example.com"
mock_settings.email_port = 587
@@ -406,7 +394,6 @@ def test_upload_to_email_accepts_file_id(sample_text_file):
def test_upload_to_ftp_file_not_found():
"""Test that upload_to_ftp raises error for missing file."""
with patch("app.tasks.upload_to_ftp.settings") as mock_settings, patch("app.tasks.upload_to_ftp.log_task_progress"):
mock_settings.ftp_host = "ftp.example.com"
with pytest.raises(FileNotFoundError):
@@ -420,7 +407,6 @@ def test_upload_to_sftp_file_not_found():
patch("app.tasks.upload_to_sftp.settings") as mock_settings,
patch("app.tasks.upload_to_sftp.log_task_progress"),
):
mock_settings.sftp_host = "sftp.example.com"
mock_settings.sftp_port = 22
mock_settings.sftp_username = "test_user"
@@ -436,7 +422,6 @@ def test_upload_to_webdav_file_not_found():
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
with pytest.raises(FileNotFoundError):
@@ -504,7 +489,6 @@ def test_send_to_all_calls_upload_tasks_with_keyword_argument():
patch("app.tasks.send_to_all.SessionLocal"),
patch("app.tasks.send_to_all.get_configured_services_from_validator") as mock_validator,
):
# Configure validator to return S3 as configured
mock_validator.return_value = {"s3": True}
-1
View File
@@ -1,6 +1,5 @@
"""Additional tests for upload task modules."""
import os
from unittest.mock import MagicMock, patch
import pytest
-3
View File
@@ -1,8 +1,5 @@
"""Tests to increase coverage for upload task modules."""
import os
from unittest.mock import MagicMock, patch
import pytest
from app.tasks.upload_to_ftp import upload_to_ftp
+2 -5
View File
@@ -5,8 +5,7 @@ Covers _validate_dropbox_settings, get_dropbox_access_token, get_dropbox_client,
and upload_to_dropbox Celery task.
"""
import os
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
from dropbox.exceptions import ApiError, AuthError
@@ -255,9 +254,7 @@ class TestUploadToDropbox:
@patch("app.tasks.upload_to_dropbox.get_dropbox_client")
@patch("app.tasks.upload_to_dropbox.log_task_progress")
@patch("app.tasks.upload_to_dropbox.settings")
def test_large_file_chunked_upload(
self, mock_settings, mock_log, mock_client, mock_extract, mock_unique, tmp_path
):
def test_large_file_chunked_upload(self, mock_settings, mock_log, mock_client, mock_extract, mock_unique, tmp_path):
"""Test chunked upload for large files (>10MB)."""
from app.tasks.upload_to_dropbox import upload_to_dropbox
+1 -2
View File
@@ -5,8 +5,7 @@ Covers the upload_to_nextcloud Celery task including configuration validation,
WebDAV upload, directory creation, and error handling.
"""
import os
from unittest.mock import MagicMock, Mock, call, patch
from unittest.mock import Mock, patch
import pytest
+1 -2
View File
@@ -5,8 +5,7 @@ Covers get_onedrive_token, create_upload_session, upload_large_file,
and upload_to_onedrive Celery task.
"""
import os
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
+4 -11
View File
@@ -6,8 +6,7 @@ get_custom_field_id, set_document_custom_fields) and the upload_to_paperless Cel
"""
import json
import os
from unittest.mock import MagicMock, Mock, mock_open, patch
from unittest.mock import Mock, patch
import pytest
import requests
@@ -152,9 +151,7 @@ class TestPollTaskForDocumentId:
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = {
"results": [{"status": "SUCCESS", "related_document": "99"}]
}
mock_response.json.return_value = {"results": [{"status": "SUCCESS", "related_document": "99"}]}
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
@@ -188,9 +185,7 @@ class TestPollTaskForDocumentId:
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = [
{"status": "FAILURE", "result": "Not consuming duplicate document"}
]
mock_response.json.return_value = [{"status": "FAILURE", "result": "Not consuming duplicate document"}]
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
@@ -262,9 +257,7 @@ class TestGetCustomFieldId:
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = {
"results": [{"name": "sender", "id": 5}, {"name": "date", "id": 6}]
}
mock_response.json.return_value = {"results": [{"name": "sender", "id": 5}, {"name": "date", "id": 6}]}
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
+2 -23
View File
@@ -1,10 +1,10 @@
"""Comprehensive tests for upload_to_webdav task."""
import os
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
from requests.exceptions import ConnectionError, RequestException, Timeout
from requests.exceptions import ConnectionError, Timeout
from app.tasks.upload_to_webdav import upload_to_webdav
@@ -23,7 +23,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress") as mock_log,
):
# Setup settings
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
@@ -65,7 +64,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -89,7 +87,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -111,7 +108,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = None
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -125,7 +121,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
with pytest.raises(FileNotFoundError, match="File not found"):
@@ -138,7 +133,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -162,7 +156,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -185,7 +178,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -208,7 +200,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -229,7 +220,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -250,7 +240,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -277,7 +266,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -303,7 +291,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -329,7 +316,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -352,7 +338,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -377,7 +362,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -402,7 +386,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "custom_user"
mock_settings.webdav_password = _TEST_CUSTOM_CREDENTIAL
@@ -427,7 +410,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress") as mock_log,
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -455,7 +437,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress") as mock_log,
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -482,7 +463,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
@@ -509,7 +489,6 @@ class TestUploadToWebDAV:
patch("app.tasks.upload_to_webdav.requests.put") as mock_put,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = "https://webdav.example.com/"
mock_settings.webdav_username = "test_user"
mock_settings.webdav_password = _TEST_CREDENTIAL
+3 -11
View File
@@ -7,7 +7,6 @@ actual file uploads against it, then verify the files were uploaded successfully
import os
import time
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -87,7 +86,6 @@ class TestWebDAVIntegration:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
# Configure settings to point to real WebDAV server
mock_settings.webdav_url = webdav_server["url"] + "/"
mock_settings.webdav_username = webdav_server["username"]
@@ -124,7 +122,6 @@ class TestWebDAVIntegration:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
# Create a test folder first
folder_name = "test-uploads"
folder_url = f"{webdav_server['url']}/{folder_name}"
@@ -162,7 +159,6 @@ class TestWebDAVIntegration:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = webdav_server["url"] + "/"
mock_settings.webdav_username = webdav_server["username"]
mock_settings.webdav_password = webdav_server["password"]
@@ -193,7 +189,6 @@ class TestWebDAVIntegration:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = webdav_server["url"] + "/"
mock_settings.webdav_username = "wronguser"
mock_settings.webdav_password = _TEST_WRONG_CREDENTIAL
@@ -211,7 +206,6 @@ class TestWebDAVIntegration:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = webdav_server["url"] + "/"
mock_settings.webdav_username = webdav_server["username"]
mock_settings.webdav_password = webdav_server["password"]
@@ -253,7 +247,6 @@ class TestWebDAVIntegration:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = webdav_server["url"] + "/"
mock_settings.webdav_username = webdav_server["username"]
mock_settings.webdav_password = webdav_server["password"]
@@ -291,7 +284,6 @@ class TestWebDAVIntegration:
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
patch("app.tasks.upload_to_webdav.log_task_progress"),
):
mock_settings.webdav_url = webdav_server["url"] + "/"
mock_settings.webdav_username = webdav_server["username"]
mock_settings.webdav_password = webdav_server["password"]
@@ -313,9 +305,9 @@ class TestWebDAVIntegration:
response = requests.get(file_url, auth=(webdav_server["username"], webdav_server["password"]), timeout=10)
assert response.status_code == 200
assert (
len(response.content) == 1024 * 1024
), f"File size mismatch: expected 1MB, got {len(response.content)} bytes"
assert len(response.content) == 1024 * 1024, (
f"File size mismatch: expected 1MB, got {len(response.content)} bytes"
)
@pytest.mark.integration
+2 -5
View File
@@ -5,9 +5,8 @@ Extends existing tests with comprehensive coverage for upload_with_rclone
and send_to_all_rclone_destinations Celery tasks.
"""
import os
import subprocess
from unittest.mock import MagicMock, Mock, call, patch
from unittest.mock import Mock, patch
import pytest
@@ -117,9 +116,7 @@ class TestUploadWithRcloneExtended:
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype = drive\n")
mock_run.side_effect = subprocess.CalledProcessError(
1, "rclone", stderr=b"mkdir failed"
)
mock_run.side_effect = subprocess.CalledProcessError(1, "rclone", stderr=b"mkdir failed")
with pytest.raises(RuntimeError, match="Rclone error"):
upload_with_rclone(str(test_file), "gdrive:uploads")
+1 -1
View File
@@ -4,7 +4,7 @@ Tests for app/tasks/uptime_kuma_tasks.py
Tests Uptime Kuma health check ping functionality.
"""
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
import requests
-1
View File
@@ -2,7 +2,6 @@
Tests for URL-based file upload functionality
"""
import os
from unittest.mock import Mock, patch
import pytest
-2
View File
@@ -1,7 +1,5 @@
"""Additional view tests to increase coverage."""
from unittest.mock import MagicMock, patch
import pytest
_TEST_CREDENTIAL = "test" # noqa: S105
+91 -87
View File
@@ -6,8 +6,7 @@ Target: Bring coverage from 8.77% to 70%+
"""
import json
import os
from unittest.mock import Mock, MagicMock, patch
from unittest.mock import Mock, patch
import pytest
from fastapi.testclient import TestClient
@@ -32,14 +31,14 @@ class TestFilesPage:
original_filename="test1.pdf",
local_filename="/tmp/test1.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="test2.pdf",
local_filename="/tmp/test2.pdf",
file_size=2048,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file1)
db_session.add(file2)
@@ -57,7 +56,7 @@ class TestFilesPage:
original_filename=f"test{i}.pdf",
local_filename=f"/tmp/test{i}.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -77,14 +76,14 @@ class TestFilesPage:
original_filename="invoice.pdf",
local_filename="/tmp/invoice.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="receipt.pdf",
local_filename="/tmp/receipt.pdf",
file_size=2048,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file1)
db_session.add(file2)
@@ -100,14 +99,14 @@ class TestFilesPage:
original_filename="doc.pdf",
local_filename="/tmp/doc.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="image.jpg",
local_filename="/tmp/image.jpg",
file_size=2048,
mime_type="image/jpeg"
mime_type="image/jpeg",
)
db_session.add(file1)
db_session.add(file2)
@@ -123,7 +122,7 @@ class TestFilesPage:
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -133,8 +132,20 @@ class TestFilesPage:
def test_files_page_sorting_by_filename_asc(self, client: TestClient, db_session):
"""Test sorting by filename ascending."""
file1 = FileRecord(filehash="hash1", original_filename="aaa.pdf", local_filename="/tmp/aaa.pdf", file_size=1024, mime_type="application/pdf")
file2 = FileRecord(filehash="hash2", original_filename="zzz.pdf", local_filename="/tmp/zzz.pdf", file_size=2048, mime_type="application/pdf")
file1 = FileRecord(
filehash="hash1",
original_filename="aaa.pdf",
local_filename="/tmp/aaa.pdf",
file_size=1024,
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="zzz.pdf",
local_filename="/tmp/zzz.pdf",
file_size=2048,
mime_type="application/pdf",
)
db_session.add(file1)
db_session.add(file2)
db_session.commit()
@@ -144,8 +155,20 @@ class TestFilesPage:
def test_files_page_sorting_by_size_desc(self, client: TestClient, db_session):
"""Test sorting by file size descending."""
file1 = FileRecord(filehash="hash1", original_filename="small.pdf", local_filename="/tmp/small.pdf", file_size=100, mime_type="application/pdf")
file2 = FileRecord(filehash="hash2", original_filename="large.pdf", local_filename="/tmp/large.pdf", file_size=10000, mime_type="application/pdf")
file1 = FileRecord(
filehash="hash1",
original_filename="small.pdf",
local_filename="/tmp/small.pdf",
file_size=100,
mime_type="application/pdf",
)
file2 = FileRecord(
filehash="hash2",
original_filename="large.pdf",
local_filename="/tmp/large.pdf",
file_size=10000,
mime_type="application/pdf",
)
db_session.add(file1)
db_session.add(file2)
db_session.commit()
@@ -170,14 +193,14 @@ class TestFileDetailPage:
# Create file with paths that exist
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(file_path),
original_file_path=str(file_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -194,13 +217,13 @@ class TestFileDetailPage:
"""Test file detail page includes processing logs."""
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(file_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -211,14 +234,10 @@ class TestFileDetailPage:
task_id="task1",
step_name="create_file_record",
status="success",
message="File record created"
message="File record created",
)
log2 = ProcessingLog(
file_id=file.id,
task_id="task2",
step_name="extract_text",
status="success",
message="Text extracted"
file_id=file.id, task_id="task2", step_name="extract_text", status="success", message="Text extracted"
)
db_session.add(log1)
db_session.add(log2)
@@ -231,23 +250,23 @@ class TestFileDetailPage:
"""Test file detail page loads GPT metadata from JSON file."""
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
# Create processed file path
processed_path = tmp_path / "test_processed.pdf"
processed_path.write_bytes(b"%PDF-1.4")
# Create metadata JSON file
metadata_path = tmp_path / "test_processed.json"
metadata = {"document_type": "invoice", "amount": 100.00}
metadata_path.write_text(json.dumps(metadata))
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(file_path),
processed_file_path=str(processed_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -264,7 +283,7 @@ class TestFileDetailPage:
local_filename="/nonexistent/local.pdf", # Required field
original_file_path="/nonexistent/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -287,24 +306,14 @@ class TestComputeProcessingFlow:
def test_compute_processing_flow_basic(self, db_session):
"""Test basic processing flow computation."""
from app.views.files import _compute_processing_flow
logs = [
Mock(
step_name="create_file_record",
status="success",
message="Created",
timestamp=Mock(),
task_id="task1"
step_name="create_file_record", status="success", message="Created", timestamp=Mock(), task_id="task1"
),
Mock(
step_name="check_text",
status="success",
message="Checked",
timestamp=Mock(),
task_id="task2"
)
Mock(step_name="check_text", status="success", message="Checked", timestamp=Mock(), task_id="task2"),
]
flow = _compute_processing_flow(logs)
assert isinstance(flow, list)
assert len(flow) > 0
@@ -314,17 +323,17 @@ class TestComputeProcessingFlow:
def test_compute_processing_flow_with_deduplication(self, db_session):
"""Test flow includes deduplication when enabled."""
from app.views.files import _compute_processing_flow
logs = [
Mock(
step_name="check_for_duplicates",
status="success",
message="No duplicates",
timestamp=Mock(),
task_id="task1"
task_id="task1",
)
]
flow = _compute_processing_flow(logs)
# Should include deduplication step
step_keys = [step["key"] for step in flow]
@@ -333,31 +342,27 @@ class TestComputeProcessingFlow:
def test_compute_processing_flow_with_upload_branches(self, db_session):
"""Test flow includes upload branches."""
from app.views.files import _compute_processing_flow
logs = [
Mock(
step_name="send_to_all_destinations",
status="success",
message="Sent",
timestamp=Mock(),
task_id="task1"
task_id="task1",
),
Mock(
step_name="upload_to_dropbox",
status="success",
message="Uploaded",
timestamp=Mock(),
task_id="task2"
step_name="upload_to_dropbox", status="success", message="Uploaded", timestamp=Mock(), task_id="task2"
),
Mock(
step_name="upload_to_google_drive",
status="failure",
message="Failed",
timestamp=Mock(),
task_id="task3"
)
task_id="task3",
),
]
flow = _compute_processing_flow(logs)
# Find the upload stage
upload_stage = next((s for s in flow if s.get("is_branch_parent")), None)
@@ -368,17 +373,17 @@ class TestComputeProcessingFlow:
def test_compute_processing_flow_handles_failure_status(self, db_session):
"""Test flow correctly identifies failed steps."""
from app.views.files import _compute_processing_flow
logs = [
Mock(
step_name="extract_metadata_with_gpt",
status="failure",
message="Failed to extract",
timestamp=Mock(),
task_id="task1"
task_id="task1",
)
]
flow = _compute_processing_flow(logs)
failed_steps = [s for s in flow if s["status"] == "failure"]
# Should have at least the failed step we added
@@ -394,13 +399,13 @@ class TestComputeStepSummary:
def test_compute_step_summary_basic(self):
"""Test basic step summary computation."""
from app.views.files import _compute_step_summary
logs = [
Mock(step_name="create_file_record", status="success", timestamp=Mock()),
Mock(step_name="check_text", status="success", timestamp=Mock()),
Mock(step_name="extract_text", status="success", timestamp=Mock())
Mock(step_name="extract_text", status="success", timestamp=Mock()),
]
summary = _compute_step_summary(logs)
assert "main" in summary
assert "uploads" in summary
@@ -409,13 +414,13 @@ class TestComputeStepSummary:
def test_compute_step_summary_with_uploads(self):
"""Test summary includes upload task counts."""
from app.views.files import _compute_step_summary
logs = [
Mock(step_name="create_file_record", status="success", timestamp=Mock()),
Mock(step_name="upload_to_dropbox", status="success", timestamp=Mock()),
Mock(step_name="upload_to_google_drive", status="failure", timestamp=Mock())
Mock(step_name="upload_to_google_drive", status="failure", timestamp=Mock()),
]
summary = _compute_step_summary(logs)
assert summary["uploads"]["success"] >= 1
assert summary["uploads"]["failure"] >= 1
@@ -423,26 +428,25 @@ class TestComputeStepSummary:
def test_compute_step_summary_normalizes_pending_status(self):
"""Test that 'pending' status is normalized to 'queued'."""
from app.views.files import _compute_step_summary
logs = [
Mock(step_name="create_file_record", status="pending", timestamp=Mock())
]
logs = [Mock(step_name="create_file_record", status="pending", timestamp=Mock())]
summary = _compute_step_summary(logs)
# Should count as queued, not pending
assert summary["main"]["queued"] >= 1
def test_compute_step_summary_order_independent(self):
"""Test that summary is order-independent (uses latest timestamp)."""
from app.views.files import _compute_step_summary
from datetime import datetime, timedelta
from app.views.files import _compute_step_summary
now = datetime.now()
logs = [
Mock(step_name="create_file_record", status="queued", timestamp=now),
Mock(step_name="create_file_record", status="success", timestamp=now + timedelta(seconds=10))
Mock(step_name="create_file_record", status="success", timestamp=now + timedelta(seconds=10)),
]
summary = _compute_step_summary(logs)
# Should count success (latest) not queued
assert summary["main"]["success"] >= 1
@@ -457,14 +461,14 @@ class TestPreviewOriginalFile:
"""Test preview of original file."""
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(file_path), # Required field
original_file_path=str(file_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -487,7 +491,7 @@ class TestPreviewOriginalFile:
local_filename="/nonexistent/local.pdf", # Required field
original_file_path="/nonexistent/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -504,14 +508,14 @@ class TestPreviewProcessedFile:
"""Test preview of processed file."""
processed_path = tmp_path / "test_processed.pdf"
processed_path.write_bytes(b"%PDF-1.4")
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(processed_path), # Required field
processed_file_path=str(processed_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -533,7 +537,7 @@ class TestPreviewProcessedFile:
local_filename="/nonexistent/local.pdf", # Required field
processed_file_path="/nonexistent/test_processed.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -573,14 +577,14 @@ startxref
%%EOF
"""
pdf_path.write_bytes(pdf_content)
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(pdf_path), # Required field
original_file_path=str(pdf_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -604,7 +608,7 @@ startxref
local_filename="/nonexistent/local.pdf", # Required field
original_file_path="/nonexistent/test.pdf",
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -643,14 +647,14 @@ startxref
%%EOF
"""
pdf_path.write_bytes(pdf_content)
file = FileRecord(
filehash="hash1",
original_filename="test.pdf",
local_filename=str(pdf_path), # local_filename is NOT NULL
processed_file_path=str(pdf_path),
file_size=1024,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
@@ -693,14 +697,14 @@ startxref
%%EOF
"""
pdf_path.write_bytes(pdf_content)
file = FileRecord(
filehash="hash1",
original_filename="empty.pdf",
local_filename=str(pdf_path), # local_filename is NOT NULL
processed_file_path=str(pdf_path),
file_size=100,
mime_type="application/pdf"
mime_type="application/pdf",
)
db_session.add(file)
db_session.commit()
-3
View File
@@ -1,8 +1,5 @@
"""Tests for app/views/general.py module."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
+1 -1
View File
@@ -1,6 +1,6 @@
"""Tests for app/views/settings.py module."""
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest