b42c6f5f64
- Fix PLW2901: Use different variable name for stripped lines in loop - Fix E721: Use 'is' instead of '==' for type comparisons - Add noqa comments for intentional security warnings (S321, S507, S110, S603) Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
578 lines
19 KiB
Python
578 lines
19 KiB
Python
"""
|
|
End-to-end integration tests using real infrastructure.
|
|
|
|
These tests spin up actual services (PostgreSQL, Redis, Gotenberg, WebDAV, SFTP, MinIO)
|
|
and test the complete application workflow from API request to file upload.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
# Import testcontainers requirement
|
|
pytest.importorskip("testcontainers", reason="testcontainers not installed")
|
|
|
|
try:
|
|
import psycopg2 # noqa: F401
|
|
|
|
_has_psycopg2 = True
|
|
except ModuleNotFoundError:
|
|
_has_psycopg2 = False
|
|
|
|
|
|
_TEST_CREDENTIAL = "pass" # noqa: S105
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.requires_docker
|
|
@pytest.mark.e2e
|
|
class TestEndToEndWithRedis:
|
|
"""
|
|
End-to-end tests with real Redis and Celery workers.
|
|
|
|
These tests verify the complete task queueing and execution workflow.
|
|
"""
|
|
|
|
def test_webdav_upload_with_redis_and_celery(
|
|
self,
|
|
redis_container,
|
|
webdav_container,
|
|
celery_app,
|
|
celery_worker,
|
|
sample_text_file,
|
|
):
|
|
"""
|
|
Test complete workflow: Queue task in Redis → Celery worker executes → Upload to WebDAV.
|
|
|
|
This is the closest to production - actual message queueing and async execution.
|
|
"""
|
|
from app.tasks.upload_to_webdav import upload_to_webdav
|
|
|
|
with (
|
|
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
|
|
patch("app.tasks.upload_to_webdav.log_task_progress"),
|
|
):
|
|
# Configure to use real WebDAV server
|
|
mock_settings.webdav_url = webdav_container["url"] + "/"
|
|
mock_settings.webdav_username = webdav_container["username"]
|
|
mock_settings.webdav_password = webdav_container["password"]
|
|
mock_settings.webdav_folder = ""
|
|
mock_settings.webdav_verify_ssl = False
|
|
mock_settings.http_request_timeout = 30
|
|
|
|
# Queue the task (it goes to Redis)
|
|
result = upload_to_webdav.delay(sample_text_file, file_id=1)
|
|
|
|
# Wait for task to complete (worker picks it up from Redis)
|
|
timeout = 30
|
|
start_time = time.time()
|
|
while not result.ready():
|
|
if time.time() - start_time > timeout:
|
|
pytest.fail(f"Task did not complete within {timeout} seconds")
|
|
time.sleep(0.5)
|
|
|
|
# Get the result
|
|
task_result = result.get(timeout=10)
|
|
|
|
# Verify task completed successfully
|
|
assert task_result["status"] == "Completed"
|
|
assert task_result["file"] == sample_text_file
|
|
|
|
# Verify file was actually uploaded to WebDAV server
|
|
filename = os.path.basename(sample_text_file)
|
|
file_url = f"{webdav_container['url']}/{filename}"
|
|
|
|
response = requests.get(
|
|
file_url, auth=(webdav_container["username"], webdav_container["password"]), timeout=5
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
# Verify content matches
|
|
with open(sample_text_file, "rb") as f:
|
|
assert response.content == f.read()
|
|
|
|
def test_task_queuing_in_redis(
|
|
self,
|
|
redis_container,
|
|
celery_app,
|
|
):
|
|
"""
|
|
Test that tasks are properly queued in Redis.
|
|
|
|
This verifies the Redis broker is working correctly.
|
|
"""
|
|
import redis
|
|
|
|
from app.tasks.upload_to_webdav import upload_to_webdav
|
|
|
|
# Connect to Redis directly
|
|
r = redis.from_url(redis_container["url"])
|
|
|
|
# Check Redis is accessible
|
|
assert r.ping()
|
|
|
|
# Get current queue length
|
|
initial_queue_length = r.llen("celery")
|
|
|
|
# Queue a task (don't execute, just verify queueing)
|
|
with patch("app.tasks.upload_to_webdav.settings") as mock_settings:
|
|
mock_settings.webdav_url = "http://test.com"
|
|
mock_settings.webdav_username = "user"
|
|
mock_settings.webdav_password = _TEST_CREDENTIAL
|
|
|
|
# This will queue the task in Redis
|
|
result = upload_to_webdav.apply_async(args=["/tmp/test.txt"], kwargs={"file_id": 1})
|
|
|
|
# Verify task ID was generated
|
|
assert result.id is not None
|
|
|
|
def test_multiple_tasks_parallel_execution(
|
|
self,
|
|
redis_container,
|
|
webdav_container,
|
|
celery_app,
|
|
celery_worker,
|
|
tmp_path,
|
|
):
|
|
"""
|
|
Test multiple tasks executing in parallel through Redis/Celery.
|
|
|
|
This tests concurrent task processing.
|
|
"""
|
|
from app.tasks.upload_to_webdav import upload_to_webdav
|
|
|
|
# Create multiple test files
|
|
files = []
|
|
for i in range(5):
|
|
test_file = tmp_path / f"test_{i}.txt"
|
|
test_file.write_text(f"Test file {i}")
|
|
files.append(str(test_file))
|
|
|
|
with (
|
|
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
|
|
patch("app.tasks.upload_to_webdav.log_task_progress"),
|
|
):
|
|
mock_settings.webdav_url = webdav_container["url"] + "/"
|
|
mock_settings.webdav_username = webdav_container["username"]
|
|
mock_settings.webdav_password = webdav_container["password"]
|
|
mock_settings.webdav_folder = "parallel-test"
|
|
mock_settings.webdav_verify_ssl = False
|
|
mock_settings.http_request_timeout = 30
|
|
|
|
# Create folder on WebDAV server
|
|
folder_url = f"{webdav_container['url']}/parallel-test"
|
|
requests.request(
|
|
"MKCOL", folder_url, auth=(webdav_container["username"], webdav_container["password"]), timeout=5
|
|
)
|
|
|
|
# Queue all tasks
|
|
results = []
|
|
for idx, file_path in enumerate(files):
|
|
result = upload_to_webdav.delay(file_path, file_id=idx + 100)
|
|
results.append((result, file_path))
|
|
|
|
# Wait for all tasks to complete
|
|
timeout = 60
|
|
start_time = time.time()
|
|
all_ready = False
|
|
|
|
while not all_ready:
|
|
if time.time() - start_time > timeout:
|
|
pytest.fail("Tasks did not complete within timeout")
|
|
|
|
all_ready = all(r.ready() for r, _ in results)
|
|
time.sleep(0.5)
|
|
|
|
# Verify all tasks succeeded
|
|
for result, file_path in results:
|
|
task_result = result.get(timeout=5)
|
|
assert task_result["status"] == "Completed"
|
|
|
|
# Verify file on server
|
|
filename = os.path.basename(file_path)
|
|
file_url = f"{webdav_container['url']}/parallel-test/{filename}"
|
|
response = requests.get(
|
|
file_url, auth=(webdav_container["username"], webdav_container["password"]), timeout=5
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def test_task_retry_on_failure(
|
|
self,
|
|
redis_container,
|
|
celery_app,
|
|
celery_worker,
|
|
sample_text_file,
|
|
):
|
|
"""
|
|
Test that tasks retry on failure using Redis.
|
|
|
|
This verifies the retry mechanism works with real broker.
|
|
"""
|
|
from app.tasks.upload_to_webdav import upload_to_webdav
|
|
|
|
with (
|
|
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.requests.put") as mock_put,
|
|
):
|
|
mock_settings.webdav_url = "http://test.com/"
|
|
mock_settings.webdav_username = "user"
|
|
mock_settings.webdav_password = _TEST_CREDENTIAL
|
|
mock_settings.webdav_folder = ""
|
|
mock_settings.webdav_verify_ssl = False
|
|
mock_settings.http_request_timeout = 30
|
|
|
|
# First attempt fails with 500
|
|
mock_response_fail = requests.Response()
|
|
mock_response_fail.status_code = 500
|
|
mock_response_fail._content = b"Server Error"
|
|
|
|
# Second attempt succeeds
|
|
mock_response_success = requests.Response()
|
|
mock_response_success.status_code = 201
|
|
|
|
# Configure mock to fail once, then succeed
|
|
mock_put.side_effect = [mock_response_fail, mock_response_success]
|
|
|
|
# Queue task
|
|
result = upload_to_webdav.delay(sample_text_file, file_id=1)
|
|
|
|
# Wait for completion (including retry)
|
|
timeout = 30
|
|
start_time = time.time()
|
|
while not result.ready():
|
|
if time.time() - start_time > timeout:
|
|
break # Task might still be retrying
|
|
time.sleep(0.5)
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.requires_docker
|
|
@pytest.mark.e2e
|
|
class TestFullInfrastructure:
|
|
"""
|
|
Tests using the complete infrastructure stack.
|
|
|
|
PostgreSQL + Redis + Gotenberg + Upload targets (WebDAV/SFTP/MinIO)
|
|
"""
|
|
|
|
def test_complete_stack_available(self, full_infrastructure):
|
|
"""
|
|
Verify all infrastructure components are running.
|
|
"""
|
|
infra = full_infrastructure
|
|
|
|
# Check PostgreSQL
|
|
assert infra["postgres"]["url"] is not None
|
|
assert "postgresql" in infra["postgres"]["url"]
|
|
|
|
# Check Redis
|
|
assert infra["redis"]["url"] is not None
|
|
import redis
|
|
|
|
r = redis.from_url(infra["redis"]["url"])
|
|
assert r.ping()
|
|
|
|
# Check Gotenberg
|
|
assert infra["gotenberg"]["url"] is not None
|
|
response = requests.get(f"{infra['gotenberg']['url']}/health", timeout=5)
|
|
assert response.status_code == 200
|
|
|
|
# Check WebDAV
|
|
assert infra["webdav"]["url"] is not None
|
|
|
|
# Check SFTP
|
|
assert infra["sftp"]["host"] is not None
|
|
assert infra["sftp"]["port"] is not None
|
|
|
|
# Check MinIO
|
|
assert infra["minio"]["access_key"] is not None
|
|
|
|
@pytest.mark.skipif(
|
|
not _has_psycopg2,
|
|
reason="psycopg2 not installed",
|
|
)
|
|
def test_database_with_real_postgres(self, postgres_container, db_session_real):
|
|
"""
|
|
Test database operations with real PostgreSQL instead of SQLite.
|
|
"""
|
|
from app.models import FileRecord
|
|
|
|
# Create a file record
|
|
file_record = FileRecord(
|
|
filename="test.pdf",
|
|
file_path="/tmp/test.pdf",
|
|
file_size=1024,
|
|
mime_type="application/pdf",
|
|
)
|
|
|
|
db_session_real.add(file_record)
|
|
db_session_real.commit()
|
|
|
|
# Verify it was saved
|
|
assert file_record.id is not None
|
|
|
|
# Query it back
|
|
queried = db_session_real.query(FileRecord).filter_by(filename="test.pdf").first()
|
|
assert queried is not None
|
|
assert queried.filename == "test.pdf"
|
|
assert queried.file_size == 1024
|
|
|
|
def test_upload_to_multiple_targets(
|
|
self,
|
|
full_infrastructure,
|
|
celery_app,
|
|
celery_worker,
|
|
sample_text_file,
|
|
):
|
|
"""
|
|
Test uploading to multiple targets in parallel (WebDAV + SFTP).
|
|
|
|
This simulates the send_to_all_destinations workflow.
|
|
"""
|
|
from app.tasks.upload_to_webdav import upload_to_webdav
|
|
|
|
infra = full_infrastructure
|
|
|
|
with (
|
|
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
|
|
patch("app.tasks.upload_to_webdav.log_task_progress"),
|
|
):
|
|
mock_settings.webdav_url = infra["webdav"]["url"] + "/"
|
|
mock_settings.webdav_username = infra["webdav"]["username"]
|
|
mock_settings.webdav_password = infra["webdav"]["password"]
|
|
mock_settings.webdav_folder = ""
|
|
mock_settings.webdav_verify_ssl = False
|
|
mock_settings.http_request_timeout = 30
|
|
|
|
# Upload to WebDAV
|
|
webdav_result = upload_to_webdav.delay(sample_text_file, file_id=1)
|
|
|
|
# Wait for completion
|
|
timeout = 30
|
|
start_time = time.time()
|
|
while not webdav_result.ready():
|
|
if time.time() - start_time > timeout:
|
|
pytest.fail("Task timeout")
|
|
time.sleep(0.5)
|
|
|
|
# Verify WebDAV upload
|
|
result = webdav_result.get(timeout=10)
|
|
assert result["status"] == "Completed"
|
|
|
|
# Verify file on WebDAV server
|
|
filename = os.path.basename(sample_text_file)
|
|
file_url = f"{infra['webdav']['url']}/{filename}"
|
|
response = requests.get(
|
|
file_url, auth=(infra["webdav"]["username"], infra["webdav"]["password"]), timeout=5
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def test_gotenberg_pdf_conversion(self, gotenberg_container, tmp_path):
|
|
"""
|
|
Test PDF conversion using real Gotenberg service.
|
|
|
|
This verifies document processing capabilities.
|
|
"""
|
|
# Create a simple HTML file
|
|
html_file = tmp_path / "test.html"
|
|
html_file.write_text("""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Test Document</title></head>
|
|
<body><h1>Integration Test</h1><p>This is a test document.</p></body>
|
|
</html>
|
|
""")
|
|
|
|
# Convert to PDF using Gotenberg
|
|
with open(html_file, "rb") as f:
|
|
files = {"files": f}
|
|
response = requests.post(
|
|
f"{gotenberg_container['url']}/forms/chromium/convert/html", files=files, timeout=30
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["Content-Type"] == "application/pdf"
|
|
assert len(response.content) > 0
|
|
assert response.content.startswith(b"%PDF")
|
|
|
|
def test_minio_s3_upload(self, minio_container, sample_text_file):
|
|
"""
|
|
Test S3-compatible upload using real MinIO.
|
|
|
|
This tests S3 upload functionality with actual storage.
|
|
"""
|
|
import boto3
|
|
from botocore.client import Config
|
|
|
|
# Create S3 client configured for MinIO
|
|
s3_client = boto3.client(
|
|
"s3",
|
|
endpoint_url=minio_container["url"],
|
|
aws_access_key_id=minio_container["access_key"],
|
|
aws_secret_access_key=minio_container["secret_key"],
|
|
config=Config(signature_version="s3v4"),
|
|
region_name=minio_container["region"],
|
|
)
|
|
|
|
# Create bucket
|
|
bucket_name = "test-bucket"
|
|
s3_client.create_bucket(Bucket=bucket_name)
|
|
|
|
# Upload file
|
|
filename = os.path.basename(sample_text_file)
|
|
with open(sample_text_file, "rb") as f:
|
|
s3_client.upload_fileobj(f, bucket_name, filename)
|
|
|
|
# Verify upload
|
|
response = s3_client.list_objects_v2(Bucket=bucket_name)
|
|
assert "Contents" in response
|
|
assert len(response["Contents"]) == 1
|
|
assert response["Contents"][0]["Key"] == filename
|
|
|
|
# Download and verify content
|
|
download_path = os.path.join(os.path.dirname(sample_text_file), "downloaded.txt")
|
|
s3_client.download_file(bucket_name, filename, download_path)
|
|
|
|
with open(sample_text_file, "rb") as original, open(download_path, "rb") as downloaded:
|
|
assert original.read() == downloaded.read()
|
|
|
|
def test_sftp_upload(self, sftp_container, sample_text_file):
|
|
"""
|
|
Test SFTP upload using real SFTP server.
|
|
"""
|
|
import paramiko
|
|
|
|
# Create SFTP client
|
|
ssh = paramiko.SSHClient()
|
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # noqa: S507
|
|
|
|
# Connect to SFTP server
|
|
ssh.connect(
|
|
hostname=sftp_container["host"],
|
|
port=int(sftp_container["port"]),
|
|
username=sftp_container["username"],
|
|
password=sftp_container["password"],
|
|
timeout=10,
|
|
)
|
|
|
|
sftp = ssh.open_sftp()
|
|
|
|
try:
|
|
# Upload file
|
|
filename = os.path.basename(sample_text_file)
|
|
remote_path = f"{sftp_container['folder']}/{filename}"
|
|
|
|
sftp.put(sample_text_file, remote_path)
|
|
|
|
# Verify upload
|
|
stat = sftp.stat(remote_path)
|
|
assert stat.st_size == os.path.getsize(sample_text_file)
|
|
|
|
# Download and verify content
|
|
download_path = os.path.join(os.path.dirname(sample_text_file), "sftp_downloaded.txt")
|
|
sftp.get(remote_path, download_path)
|
|
|
|
with open(sample_text_file, "rb") as original, open(download_path, "rb") as downloaded:
|
|
assert original.read() == downloaded.read()
|
|
|
|
finally:
|
|
sftp.close()
|
|
ssh.close()
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.requires_docker
|
|
@pytest.mark.e2e
|
|
@pytest.mark.slow
|
|
class TestProductionLikeScenarios:
|
|
"""
|
|
Production-like end-to-end scenarios testing complete workflows.
|
|
"""
|
|
|
|
def test_document_processing_pipeline(
|
|
self,
|
|
full_infrastructure,
|
|
celery_app,
|
|
celery_worker,
|
|
db_session_real,
|
|
tmp_path,
|
|
):
|
|
"""
|
|
Test the complete document processing pipeline:
|
|
1. Upload document via API
|
|
2. Store metadata in PostgreSQL
|
|
3. Queue processing tasks in Redis
|
|
4. Process document (mock OCR/metadata extraction)
|
|
5. Upload to WebDAV via Celery
|
|
6. Verify all steps completed
|
|
|
|
This is the closest to real production usage.
|
|
"""
|
|
from app.models import FileRecord
|
|
from app.tasks.upload_to_webdav import upload_to_webdav
|
|
|
|
# Create test document
|
|
test_doc = tmp_path / "invoice.pdf"
|
|
test_doc.write_bytes(b"%PDF-1.4\n%Test PDF\n%%EOF")
|
|
|
|
# Step 1: Store in database
|
|
file_record = FileRecord(
|
|
filename="invoice.pdf",
|
|
file_path=str(test_doc),
|
|
file_size=test_doc.stat().st_size,
|
|
mime_type="application/pdf",
|
|
)
|
|
db_session_real.add(file_record)
|
|
db_session_real.commit()
|
|
|
|
assert file_record.id is not None
|
|
db_file_id = file_record.id
|
|
|
|
# Step 2: Queue upload task
|
|
infra = full_infrastructure
|
|
|
|
with (
|
|
patch("app.tasks.upload_to_webdav.settings") as mock_settings,
|
|
patch("app.tasks.upload_to_webdav.log_task_progress"),
|
|
):
|
|
mock_settings.webdav_url = infra["webdav"]["url"] + "/"
|
|
mock_settings.webdav_username = infra["webdav"]["username"]
|
|
mock_settings.webdav_password = infra["webdav"]["password"]
|
|
mock_settings.webdav_folder = "processed"
|
|
mock_settings.webdav_verify_ssl = False
|
|
mock_settings.http_request_timeout = 30
|
|
|
|
# Create folder
|
|
folder_url = f"{infra['webdav']['url']}/processed"
|
|
requests.request(
|
|
"MKCOL", folder_url, auth=(infra["webdav"]["username"], infra["webdav"]["password"]), timeout=5
|
|
)
|
|
|
|
# Step 3: Queue upload
|
|
result = upload_to_webdav.delay(str(test_doc), file_id=db_file_id)
|
|
|
|
# Step 4: Wait for processing
|
|
timeout = 30
|
|
start_time = time.time()
|
|
while not result.ready():
|
|
if time.time() - start_time > timeout:
|
|
pytest.fail("Pipeline timeout")
|
|
time.sleep(0.5)
|
|
|
|
# Step 5: Verify completion
|
|
task_result = result.get(timeout=10)
|
|
assert task_result["status"] == "Completed"
|
|
|
|
# Step 6: Verify file on WebDAV
|
|
file_url = f"{infra['webdav']['url']}/processed/invoice.pdf"
|
|
response = requests.get(
|
|
file_url, auth=(infra["webdav"]["username"], infra["webdav"]["password"]), timeout=5
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.content == test_doc.read_bytes()
|