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